@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +86 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alerting Service for Cost Spike Detection
|
|
3
|
+
*
|
|
4
|
+
* Shared alert logic for Slack and Email notifications.
|
|
5
|
+
* Used by platform-sentinel worker (task-17.20, task-17.21).
|
|
6
|
+
*
|
|
7
|
+
* @module lib/cloudflare/alerting
|
|
8
|
+
* @created 2026-01-05
|
|
9
|
+
* @task task-17.20 - Slack webhook alerts for cost spikes
|
|
10
|
+
* @task task-17.21 - Email alerts via Resend
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ThresholdWarning, ThresholdLevel, CostBreakdown } from './costs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cost spike alert data
|
|
17
|
+
*/
|
|
18
|
+
export interface CostSpikeAlert {
|
|
19
|
+
id: string;
|
|
20
|
+
serviceType: string;
|
|
21
|
+
resourceName: string;
|
|
22
|
+
currentCost: number;
|
|
23
|
+
previousCost: number;
|
|
24
|
+
costDeltaPct: number;
|
|
25
|
+
thresholdLevel: ThresholdLevel;
|
|
26
|
+
absoluteMax: number;
|
|
27
|
+
timestamp: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Alert routing result
|
|
32
|
+
*/
|
|
33
|
+
export interface AlertResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
channel: 'slack' | 'email';
|
|
36
|
+
alertId: string;
|
|
37
|
+
rateLimited: boolean;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Slack message blocks for cost spike alert
|
|
43
|
+
*/
|
|
44
|
+
export interface SlackMessage {
|
|
45
|
+
text: string;
|
|
46
|
+
blocks: SlackBlock[];
|
|
47
|
+
attachments?: SlackAttachment[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SlackBlock {
|
|
51
|
+
type: 'section' | 'divider' | 'header' | 'context';
|
|
52
|
+
text?: {
|
|
53
|
+
type: 'mrkdwn' | 'plain_text';
|
|
54
|
+
text: string;
|
|
55
|
+
};
|
|
56
|
+
fields?: Array<{
|
|
57
|
+
type: 'mrkdwn' | 'plain_text';
|
|
58
|
+
text: string;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SlackAttachment {
|
|
63
|
+
color: string;
|
|
64
|
+
fields?: Array<{
|
|
65
|
+
title: string;
|
|
66
|
+
value: string;
|
|
67
|
+
short: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get Slack colour for threshold level
|
|
73
|
+
*/
|
|
74
|
+
export function getSeverityColour(level: ThresholdLevel): string {
|
|
75
|
+
const colours: Record<ThresholdLevel, string> = {
|
|
76
|
+
critical: '#dc3545', // Red
|
|
77
|
+
high: '#fd7e14', // Orange
|
|
78
|
+
warning: '#ffc107', // Yellow
|
|
79
|
+
normal: '#28a745', // Green
|
|
80
|
+
};
|
|
81
|
+
return colours[level] || colours.warning;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get emoji for threshold level
|
|
86
|
+
*/
|
|
87
|
+
export function getSeverityEmoji(level: ThresholdLevel): string {
|
|
88
|
+
const emojis: Record<ThresholdLevel, string> = {
|
|
89
|
+
critical: ':rotating_light:', // Siren
|
|
90
|
+
high: ':warning:', // Warning triangle
|
|
91
|
+
warning: ':yellow_circle:', // Yellow circle
|
|
92
|
+
normal: ':white_check_mark:', // Checkmark
|
|
93
|
+
};
|
|
94
|
+
return emojis[level] || emojis.warning;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format currency for display
|
|
99
|
+
*/
|
|
100
|
+
export function formatCurrency(amount: number): string {
|
|
101
|
+
return `$${amount.toFixed(2)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Format percentage for display
|
|
106
|
+
*/
|
|
107
|
+
export function formatPercentage(pct: number): string {
|
|
108
|
+
const sign = pct >= 0 ? '+' : '';
|
|
109
|
+
return `${sign}${pct.toFixed(1)}%`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate deduplication key for an alert
|
|
114
|
+
* Format: cost-spike:{serviceType}:{resourceName}
|
|
115
|
+
*/
|
|
116
|
+
export function generateAlertKey(alert: CostSpikeAlert): string {
|
|
117
|
+
return `cost-spike:${alert.serviceType}:${alert.resourceName}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if an alert should be sent (rate limiting)
|
|
122
|
+
*
|
|
123
|
+
* @param key - Alert deduplication key
|
|
124
|
+
* @param kv - KV namespace for storing rate limit state
|
|
125
|
+
* @param ttlSeconds - Rate limit window (default: 1 hour)
|
|
126
|
+
* @returns true if alert should be sent, false if rate limited
|
|
127
|
+
*/
|
|
128
|
+
export async function shouldSendAlert(
|
|
129
|
+
key: string,
|
|
130
|
+
kv: KVNamespace,
|
|
131
|
+
ttlSeconds: number = 3600
|
|
132
|
+
): Promise<boolean> {
|
|
133
|
+
const existing = await kv.get(key);
|
|
134
|
+
if (existing) {
|
|
135
|
+
return false; // Rate limited
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Mark as sent with TTL
|
|
139
|
+
await kv.put(key, new Date().toISOString(), {
|
|
140
|
+
expirationTtl: ttlSeconds,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build Slack message for cost spike alert
|
|
148
|
+
*/
|
|
149
|
+
export function buildSlackMessage(alert: CostSpikeAlert): SlackMessage {
|
|
150
|
+
const emoji = getSeverityEmoji(alert.thresholdLevel);
|
|
151
|
+
const colour = getSeverityColour(alert.thresholdLevel);
|
|
152
|
+
const levelText = alert.thresholdLevel.toUpperCase();
|
|
153
|
+
const deltaText = formatPercentage(alert.costDeltaPct);
|
|
154
|
+
const isSpike = alert.costDeltaPct > 0;
|
|
155
|
+
|
|
156
|
+
const headerText = isSpike
|
|
157
|
+
? `${emoji} Cost Spike Detected: ${alert.serviceType}`
|
|
158
|
+
: `${emoji} Cost Alert: ${alert.serviceType}`;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
text: `[${levelText}] ${alert.serviceType} cost ${deltaText} - ${formatCurrency(alert.currentCost)}`,
|
|
162
|
+
blocks: [
|
|
163
|
+
{
|
|
164
|
+
type: 'header',
|
|
165
|
+
text: {
|
|
166
|
+
type: 'plain_text',
|
|
167
|
+
text: headerText,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'section',
|
|
172
|
+
fields: [
|
|
173
|
+
{
|
|
174
|
+
type: 'mrkdwn',
|
|
175
|
+
text: `*Service:*\n${alert.serviceType}`,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: 'mrkdwn',
|
|
179
|
+
text: `*Resource:*\n${alert.resourceName}`,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
type: 'mrkdwn',
|
|
183
|
+
text: `*Current Cost:*\n${formatCurrency(alert.currentCost)}`,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
type: 'mrkdwn',
|
|
187
|
+
text: `*Previous Cost:*\n${formatCurrency(alert.previousCost)}`,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
type: 'mrkdwn',
|
|
191
|
+
text: `*Change:*\n${deltaText}`,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
type: 'mrkdwn',
|
|
195
|
+
text: `*Threshold:*\n${formatCurrency(alert.absoluteMax)} (${levelText})`,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
type: 'context',
|
|
201
|
+
text: {
|
|
202
|
+
type: 'mrkdwn',
|
|
203
|
+
text: `Alert ID: ${alert.id} | Time: ${new Date(alert.timestamp).toLocaleString('en-AU')}`,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
attachments: [
|
|
208
|
+
{
|
|
209
|
+
color: colour,
|
|
210
|
+
fields: [
|
|
211
|
+
{
|
|
212
|
+
title: 'Action Required',
|
|
213
|
+
value:
|
|
214
|
+
alert.thresholdLevel === 'critical'
|
|
215
|
+
? 'Investigate immediately - usage significantly exceeds budget'
|
|
216
|
+
: alert.thresholdLevel === 'high'
|
|
217
|
+
? 'Review usage patterns and consider optimisation'
|
|
218
|
+
: 'Monitor closely - approaching threshold',
|
|
219
|
+
short: false,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build summary Slack message for multiple alerts
|
|
229
|
+
*/
|
|
230
|
+
export function buildSummarySlackMessage(alerts: CostSpikeAlert[]): SlackMessage {
|
|
231
|
+
const criticalCount = alerts.filter((a) => a.thresholdLevel === 'critical').length;
|
|
232
|
+
const highCount = alerts.filter((a) => a.thresholdLevel === 'high').length;
|
|
233
|
+
const warningCount = alerts.filter((a) => a.thresholdLevel === 'warning').length;
|
|
234
|
+
|
|
235
|
+
const emoji =
|
|
236
|
+
criticalCount > 0 ? ':rotating_light:' : highCount > 0 ? ':warning:' : ':yellow_circle:';
|
|
237
|
+
const totalCost = alerts.reduce((sum, a) => sum + a.currentCost, 0);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
text: `[SUMMARY] ${alerts.length} cost alerts - ${formatCurrency(totalCost)} total`,
|
|
241
|
+
blocks: [
|
|
242
|
+
{
|
|
243
|
+
type: 'header',
|
|
244
|
+
text: {
|
|
245
|
+
type: 'plain_text',
|
|
246
|
+
text: `${emoji} Cost Alert Summary: ${alerts.length} Alerts`,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
type: 'section',
|
|
251
|
+
fields: [
|
|
252
|
+
{
|
|
253
|
+
type: 'mrkdwn',
|
|
254
|
+
text: `*Critical:* ${criticalCount}`,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
type: 'mrkdwn',
|
|
258
|
+
text: `*High:* ${highCount}`,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
type: 'mrkdwn',
|
|
262
|
+
text: `*Warning:* ${warningCount}`,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
type: 'mrkdwn',
|
|
266
|
+
text: `*Total Cost:* ${formatCurrency(totalCost)}`,
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
type: 'divider',
|
|
272
|
+
},
|
|
273
|
+
...alerts.slice(0, 5).map(
|
|
274
|
+
(alert) =>
|
|
275
|
+
({
|
|
276
|
+
type: 'section',
|
|
277
|
+
text: {
|
|
278
|
+
type: 'mrkdwn',
|
|
279
|
+
text: `${getSeverityEmoji(alert.thresholdLevel)} *${alert.serviceType}*: ${formatCurrency(alert.currentCost)} (${formatPercentage(alert.costDeltaPct)})`,
|
|
280
|
+
},
|
|
281
|
+
}) as SlackBlock
|
|
282
|
+
),
|
|
283
|
+
...(alerts.length > 5
|
|
284
|
+
? [
|
|
285
|
+
{
|
|
286
|
+
type: 'context' as const,
|
|
287
|
+
text: {
|
|
288
|
+
type: 'mrkdwn' as const,
|
|
289
|
+
text: `_...and ${alerts.length - 5} more alerts_`,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
]
|
|
293
|
+
: []),
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Send alert to Slack webhook
|
|
300
|
+
*/
|
|
301
|
+
export async function sendSlackAlert(
|
|
302
|
+
webhookUrl: string,
|
|
303
|
+
message: SlackMessage
|
|
304
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetch(webhookUrl, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
309
|
+
body: JSON.stringify(message),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
const text = await response.text();
|
|
314
|
+
return { success: false, error: `Slack webhook failed: ${response.status} ${text}` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { success: true };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return {
|
|
320
|
+
success: false,
|
|
321
|
+
error: `Slack webhook error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Evaluate if a warning should trigger an alert
|
|
328
|
+
*
|
|
329
|
+
* @param warning - Threshold warning from analyseThresholds
|
|
330
|
+
* @param costs - Current cost breakdown
|
|
331
|
+
* @param previousCosts - Previous period cost breakdown (for delta calculation)
|
|
332
|
+
* @param absoluteMax - Maximum cost threshold
|
|
333
|
+
* @returns CostSpikeAlert if alert should be triggered, null otherwise
|
|
334
|
+
*/
|
|
335
|
+
export function evaluateWarning(
|
|
336
|
+
warning: ThresholdWarning,
|
|
337
|
+
costs: CostBreakdown,
|
|
338
|
+
previousCosts: CostBreakdown | null,
|
|
339
|
+
absoluteMax: number
|
|
340
|
+
): CostSpikeAlert | null {
|
|
341
|
+
// Map resource name to service type key
|
|
342
|
+
const serviceKeyMap: Record<string, keyof CostBreakdown> = {
|
|
343
|
+
Workers: 'workers',
|
|
344
|
+
D1: 'd1',
|
|
345
|
+
KV: 'kv',
|
|
346
|
+
R2: 'r2',
|
|
347
|
+
'Durable Objects': 'durableObjects',
|
|
348
|
+
Vectorize: 'vectorize',
|
|
349
|
+
'AI Gateway': 'aiGateway',
|
|
350
|
+
Pages: 'pages',
|
|
351
|
+
Queues: 'queues',
|
|
352
|
+
Workflows: 'workflows',
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const serviceKey = serviceKeyMap[warning.resource];
|
|
356
|
+
if (!serviceKey) return null;
|
|
357
|
+
|
|
358
|
+
const currentCost = costs[serviceKey];
|
|
359
|
+
const previousCost = previousCosts ? previousCosts[serviceKey] : 0;
|
|
360
|
+
|
|
361
|
+
// Calculate cost delta percentage
|
|
362
|
+
const costDeltaPct = previousCost > 0 ? ((currentCost - previousCost) / previousCost) * 100 : 0;
|
|
363
|
+
|
|
364
|
+
// Alert conditions (any of):
|
|
365
|
+
// 1. Cost delta > 50%
|
|
366
|
+
// 2. Threshold level is critical
|
|
367
|
+
// 3. Current cost exceeds absolute max
|
|
368
|
+
const shouldAlert =
|
|
369
|
+
costDeltaPct > 50 || warning.level === 'critical' || currentCost > absoluteMax;
|
|
370
|
+
|
|
371
|
+
if (!shouldAlert) return null;
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
id: crypto.randomUUID(),
|
|
375
|
+
serviceType: warning.resource,
|
|
376
|
+
resourceName: warning.metric,
|
|
377
|
+
currentCost,
|
|
378
|
+
previousCost,
|
|
379
|
+
costDeltaPct,
|
|
380
|
+
thresholdLevel: warning.level,
|
|
381
|
+
absoluteMax,
|
|
382
|
+
timestamp: new Date().toISOString(),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Build HTML email template for cost alert
|
|
388
|
+
*/
|
|
389
|
+
export function buildEmailHtml(alert: CostSpikeAlert): string {
|
|
390
|
+
const levelColour = getSeverityColour(alert.thresholdLevel);
|
|
391
|
+
const levelText = alert.thresholdLevel.toUpperCase();
|
|
392
|
+
const deltaText = formatPercentage(alert.costDeltaPct);
|
|
393
|
+
|
|
394
|
+
return `
|
|
395
|
+
<!DOCTYPE html>
|
|
396
|
+
<html lang="en">
|
|
397
|
+
<head>
|
|
398
|
+
<meta charset="UTF-8">
|
|
399
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
400
|
+
<title>Cost Alert: ${alert.serviceType}</title>
|
|
401
|
+
</head>
|
|
402
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;">
|
|
403
|
+
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
|
404
|
+
<div style="background-color: ${levelColour}; color: white; padding: 20px;">
|
|
405
|
+
<h1 style="margin: 0; font-size: 20px;">[${levelText}] Cost Alert: ${alert.serviceType}</h1>
|
|
406
|
+
</div>
|
|
407
|
+
<div style="padding: 20px;">
|
|
408
|
+
<table style="width: 100%; border-collapse: collapse;">
|
|
409
|
+
<tr>
|
|
410
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;"><strong>Service</strong></td>
|
|
411
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;">${alert.serviceType}</td>
|
|
412
|
+
</tr>
|
|
413
|
+
<tr>
|
|
414
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;"><strong>Resource</strong></td>
|
|
415
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;">${alert.resourceName}</td>
|
|
416
|
+
</tr>
|
|
417
|
+
<tr>
|
|
418
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;"><strong>Current Cost</strong></td>
|
|
419
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;">${formatCurrency(alert.currentCost)}</td>
|
|
420
|
+
</tr>
|
|
421
|
+
<tr>
|
|
422
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;"><strong>Previous Cost</strong></td>
|
|
423
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;">${formatCurrency(alert.previousCost)}</td>
|
|
424
|
+
</tr>
|
|
425
|
+
<tr>
|
|
426
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee;"><strong>Change</strong></td>
|
|
427
|
+
<td style="padding: 10px 0; border-bottom: 1px solid #eee; color: ${alert.costDeltaPct > 0 ? '#dc3545' : '#28a745'};">${deltaText}</td>
|
|
428
|
+
</tr>
|
|
429
|
+
<tr>
|
|
430
|
+
<td style="padding: 10px 0;"><strong>Threshold</strong></td>
|
|
431
|
+
<td style="padding: 10px 0;">${formatCurrency(alert.absoluteMax)} (${levelText})</td>
|
|
432
|
+
</tr>
|
|
433
|
+
</table>
|
|
434
|
+
<div style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 4px;">
|
|
435
|
+
<strong>Recommended Action:</strong>
|
|
436
|
+
<p style="margin: 10px 0 0 0; color: #666;">
|
|
437
|
+
${
|
|
438
|
+
alert.thresholdLevel === 'critical'
|
|
439
|
+
? 'Investigate immediately - usage significantly exceeds budget'
|
|
440
|
+
: alert.thresholdLevel === 'high'
|
|
441
|
+
? 'Review usage patterns and consider optimisation'
|
|
442
|
+
: 'Monitor closely - approaching threshold'
|
|
443
|
+
}
|
|
444
|
+
</p>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
<div style="background: #f8f9fa; padding: 15px 20px; font-size: 12px; color: #666;">
|
|
448
|
+
<p style="margin: 0;">Alert ID: ${alert.id}</p>
|
|
449
|
+
<p style="margin: 5px 0 0 0;">Generated: ${new Date(alert.timestamp).toLocaleString('en-AU')}</p>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</body>
|
|
453
|
+
</html>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build plain text email for cost alert
|
|
458
|
+
*/
|
|
459
|
+
export function buildEmailText(alert: CostSpikeAlert): string {
|
|
460
|
+
const levelText = alert.thresholdLevel.toUpperCase();
|
|
461
|
+
const deltaText = formatPercentage(alert.costDeltaPct);
|
|
462
|
+
|
|
463
|
+
return `
|
|
464
|
+
[${levelText}] Cost Alert: ${alert.serviceType}
|
|
465
|
+
|
|
466
|
+
Service: ${alert.serviceType}
|
|
467
|
+
Resource: ${alert.resourceName}
|
|
468
|
+
Current Cost: ${formatCurrency(alert.currentCost)}
|
|
469
|
+
Previous Cost: ${formatCurrency(alert.previousCost)}
|
|
470
|
+
Change: ${deltaText}
|
|
471
|
+
Threshold: ${formatCurrency(alert.absoluteMax)} (${levelText})
|
|
472
|
+
|
|
473
|
+
Recommended Action:
|
|
474
|
+
${
|
|
475
|
+
alert.thresholdLevel === 'critical'
|
|
476
|
+
? 'Investigate immediately - usage significantly exceeds budget'
|
|
477
|
+
: alert.thresholdLevel === 'high'
|
|
478
|
+
? 'Review usage patterns and consider optimisation'
|
|
479
|
+
: 'Monitor closely - approaching threshold'
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
Alert ID: ${alert.id}
|
|
484
|
+
Generated: ${new Date(alert.timestamp).toLocaleString('en-AU')}
|
|
485
|
+
`;
|
|
486
|
+
}
|