@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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. 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
+ }