@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,364 @@
1
+ /**
2
+ * AI Model Breakdown Component
3
+ *
4
+ * Displays per-model usage from Workers AI and AI Gateway.
5
+ * Shows costs, requests, and token counts by model.
6
+ */
7
+ import { useState, useEffect } from 'react';
8
+
9
+ // =============================================================================
10
+ // TYPES
11
+ // =============================================================================
12
+
13
+ interface WorkersAIModel {
14
+ model: string;
15
+ project: string;
16
+ requests: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ costUsd: number;
20
+ }
21
+
22
+ interface AIGatewayModel {
23
+ provider: string;
24
+ model: string;
25
+ gatewayId: string;
26
+ requests: number;
27
+ cachedRequests: number;
28
+ tokensIn: number;
29
+ tokensOut: number;
30
+ costUsd: number;
31
+ }
32
+
33
+ interface ApiResponse {
34
+ success: boolean;
35
+ workersAI?: {
36
+ models: WorkersAIModel[];
37
+ totalCostUsd: number;
38
+ totalRequests: number;
39
+ };
40
+ aiGateway?: {
41
+ models: AIGatewayModel[];
42
+ totalCostUsd: number;
43
+ totalRequests: number;
44
+ byProvider: Record<string, { requests: number; costUsd: number }>;
45
+ };
46
+ error?: string;
47
+ }
48
+
49
+ interface Props {
50
+ period: string;
51
+ }
52
+
53
+ // =============================================================================
54
+ // CONSTANTS
55
+ // =============================================================================
56
+
57
+ const PROVIDER_COLORS: Record<string, string> = {
58
+ openai: 'bg-green-500',
59
+ anthropic: 'bg-orange-500',
60
+ 'google-ai-studio': 'bg-blue-500',
61
+ 'workers-ai': 'bg-amber-500',
62
+ deepseek: 'bg-cyan-500',
63
+ minimax: 'bg-indigo-500',
64
+ };
65
+
66
+ const PROVIDER_LABELS: Record<string, string> = {
67
+ openai: 'OpenAI',
68
+ anthropic: 'Anthropic',
69
+ 'google-ai-studio': 'Google AI',
70
+ 'workers-ai': 'Workers AI',
71
+ deepseek: 'DeepSeek',
72
+ minimax: 'MiniMax',
73
+ };
74
+
75
+ // =============================================================================
76
+ // HELPERS
77
+ // =============================================================================
78
+
79
+ function formatNumber(n: number): string {
80
+ if (n >= 1_000_000) {
81
+ return `${(n / 1_000_000).toFixed(1)}M`;
82
+ }
83
+ if (n >= 1_000) {
84
+ return `${(n / 1_000).toFixed(1)}K`;
85
+ }
86
+ return n.toLocaleString();
87
+ }
88
+
89
+ function formatModelName(model: string): string {
90
+ // Shorten common model prefixes
91
+ return model
92
+ .replace('@cf/meta/', '')
93
+ .replace('@cf/mistral/', '')
94
+ .replace('@cf/', '')
95
+ .replace('gpt-', 'GPT-')
96
+ .replace('claude-', 'Claude ')
97
+ .replace('gemini-', 'Gemini ');
98
+ }
99
+
100
+ // =============================================================================
101
+ // COMPONENTS
102
+ // =============================================================================
103
+
104
+ function LoadingState() {
105
+ return (
106
+ <div className="animate-pulse space-y-3">
107
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
108
+ <div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
109
+ <div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function EmptyState() {
115
+ return (
116
+ <div className="text-center py-8 text-gray-500 dark:text-gray-400">
117
+ <div className="text-3xl mb-2">🤖</div>
118
+ <p className="text-sm">No AI model usage data available</p>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function ProviderBar({
124
+ byProvider,
125
+ totalCost,
126
+ }: {
127
+ byProvider: Record<string, { requests: number; costUsd: number }>;
128
+ totalCost: number;
129
+ }) {
130
+ const providers = Object.entries(byProvider).sort((a, b) => b[1].costUsd - a[1].costUsd);
131
+
132
+ if (providers.length === 0 || totalCost === 0) return null;
133
+
134
+ return (
135
+ <div className="mb-4">
136
+ <div className="flex h-3 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
137
+ {providers.map(([provider, data]) => {
138
+ const width = (data.costUsd / totalCost) * 100;
139
+ if (width < 1) return null;
140
+ return (
141
+ <div
142
+ key={provider}
143
+ className={`${PROVIDER_COLORS[provider] || 'bg-gray-400'} transition-all`}
144
+ style={{ width: `${width}%` }}
145
+ title={`${PROVIDER_LABELS[provider] || provider}: $${data.costUsd.toFixed(2)}`}
146
+ />
147
+ );
148
+ })}
149
+ </div>
150
+ <div className="flex flex-wrap gap-3 mt-2">
151
+ {providers.map(([provider, data]) => (
152
+ <div key={provider} className="flex items-center gap-1.5 text-xs">
153
+ <span className={`w-2 h-2 rounded-full ${PROVIDER_COLORS[provider] || 'bg-gray-400'}`} />
154
+ <span className="text-gray-600 dark:text-gray-400">
155
+ {PROVIDER_LABELS[provider] || provider}
156
+ </span>
157
+ <span className="text-gray-900 dark:text-white font-medium">
158
+ ${data.costUsd.toFixed(2)}
159
+ </span>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function ModelTable({ models, type }: { models: (WorkersAIModel | AIGatewayModel)[]; type: 'workersai' | 'gateway' }) {
168
+ const [expanded, setExpanded] = useState(false);
169
+ const displayModels = expanded ? models : models.slice(0, 5);
170
+ const hasMore = models.length > 5;
171
+
172
+ return (
173
+ <div className="overflow-x-auto">
174
+ <table className="w-full text-sm">
175
+ <thead>
176
+ <tr className="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
177
+ <th className="pb-2 font-medium">Model</th>
178
+ {type === 'gateway' && <th className="pb-2 font-medium">Provider</th>}
179
+ <th className="pb-2 font-medium text-right">Requests</th>
180
+ <th className="pb-2 font-medium text-right">Tokens</th>
181
+ <th className="pb-2 font-medium text-right">Cost</th>
182
+ </tr>
183
+ </thead>
184
+ <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
185
+ {displayModels.map((m, idx) => {
186
+ const isGateway = 'provider' in m;
187
+ const tokens = isGateway
188
+ ? (m as AIGatewayModel).tokensIn + (m as AIGatewayModel).tokensOut
189
+ : (m as WorkersAIModel).inputTokens + (m as WorkersAIModel).outputTokens;
190
+
191
+ return (
192
+ <tr key={idx} className="text-gray-900 dark:text-white">
193
+ <td className="py-2">
194
+ <code className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
195
+ {formatModelName(m.model)}
196
+ </code>
197
+ </td>
198
+ {type === 'gateway' && (
199
+ <td className="py-2 text-gray-600 dark:text-gray-400">
200
+ {PROVIDER_LABELS[(m as AIGatewayModel).provider] || (m as AIGatewayModel).provider}
201
+ </td>
202
+ )}
203
+ <td className="py-2 text-right font-mono">{formatNumber(m.requests)}</td>
204
+ <td className="py-2 text-right font-mono text-gray-600 dark:text-gray-400">
205
+ {formatNumber(tokens)}
206
+ </td>
207
+ <td className="py-2 text-right font-medium">${m.costUsd.toFixed(2)}</td>
208
+ </tr>
209
+ );
210
+ })}
211
+ </tbody>
212
+ </table>
213
+ {hasMore && (
214
+ <button
215
+ onClick={() => setExpanded(!expanded)}
216
+ className="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
217
+ >
218
+ {expanded ? 'Show less' : `Show ${models.length - 5} more models`}
219
+ </button>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ // =============================================================================
226
+ // MAIN COMPONENT
227
+ // =============================================================================
228
+
229
+ export default function AIModelBreakdown({ period }: Props) {
230
+ const [loading, setLoading] = useState(true);
231
+ const [error, setError] = useState<string | null>(null);
232
+ const [data, setData] = useState<ApiResponse | null>(null);
233
+ const [activeTab, setActiveTab] = useState<'gateway' | 'workersai'>('gateway');
234
+
235
+ useEffect(() => {
236
+ async function fetchData() {
237
+ setLoading(true);
238
+ setError(null);
239
+
240
+ try {
241
+ const response = await fetch(`/api/usage/ai-models?period=${period}`);
242
+ const result: ApiResponse = await response.json();
243
+
244
+ if (!result.success) {
245
+ throw new Error(result.error || 'Failed to load data');
246
+ }
247
+
248
+ setData(result);
249
+ } catch (err) {
250
+ setError(err instanceof Error ? err.message : 'Unknown error');
251
+ } finally {
252
+ setLoading(false);
253
+ }
254
+ }
255
+
256
+ fetchData();
257
+ }, [period]);
258
+
259
+ if (loading) {
260
+ return (
261
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
262
+ <h3 className="font-medium text-gray-900 dark:text-white mb-4">AI Model Usage</h3>
263
+ <LoadingState />
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (error) {
269
+ return (
270
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
271
+ <h3 className="font-medium text-gray-900 dark:text-white mb-2">AI Model Usage</h3>
272
+ <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const hasGatewayData = (data?.aiGateway?.models?.length ?? 0) > 0;
278
+ const hasWorkersAIData = (data?.workersAI?.models?.length ?? 0) > 0;
279
+
280
+ if (!hasGatewayData && !hasWorkersAIData) {
281
+ return (
282
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
283
+ <h3 className="font-medium text-gray-900 dark:text-white mb-2">AI Model Usage</h3>
284
+ <EmptyState />
285
+ </div>
286
+ );
287
+ }
288
+
289
+ return (
290
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
291
+ {/* Header with tabs */}
292
+ <div className="border-b border-gray-200 dark:border-gray-700">
293
+ <div className="flex items-center justify-between p-4 pb-0">
294
+ <h3 className="font-medium text-gray-900 dark:text-white">AI Model Usage</h3>
295
+ <div className="text-sm">
296
+ <span className="text-gray-500 dark:text-gray-400">Total: </span>
297
+ <span className="font-semibold text-gray-900 dark:text-white">
298
+ ${((data?.aiGateway?.totalCostUsd ?? 0) + (data?.workersAI?.totalCostUsd ?? 0)).toFixed(2)}
299
+ </span>
300
+ </div>
301
+ </div>
302
+
303
+ {/* Tabs */}
304
+ <div className="flex px-4 mt-3">
305
+ <button
306
+ onClick={() => setActiveTab('gateway')}
307
+ className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
308
+ activeTab === 'gateway'
309
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
310
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
311
+ }`}
312
+ >
313
+ AI Gateway
314
+ {hasGatewayData && (
315
+ <span className="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
316
+ ${data?.aiGateway?.totalCostUsd?.toFixed(2)}
317
+ </span>
318
+ )}
319
+ </button>
320
+ <button
321
+ onClick={() => setActiveTab('workersai')}
322
+ className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
323
+ activeTab === 'workersai'
324
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
325
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
326
+ }`}
327
+ >
328
+ Workers AI
329
+ {hasWorkersAIData && (
330
+ <span className="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
331
+ ${data?.workersAI?.totalCostUsd?.toFixed(2)}
332
+ </span>
333
+ )}
334
+ </button>
335
+ </div>
336
+ </div>
337
+
338
+ {/* Content */}
339
+ <div className="p-4">
340
+ {activeTab === 'gateway' && data?.aiGateway && (
341
+ <>
342
+ <ProviderBar
343
+ byProvider={data.aiGateway.byProvider}
344
+ totalCost={data.aiGateway.totalCostUsd}
345
+ />
346
+ {hasGatewayData ? (
347
+ <ModelTable models={data.aiGateway.models} type="gateway" />
348
+ ) : (
349
+ <EmptyState />
350
+ )}
351
+ </>
352
+ )}
353
+
354
+ {activeTab === 'workersai' && data?.workersAI && (
355
+ hasWorkersAIData ? (
356
+ <ModelTable models={data.workersAI.models} type="workersai" />
357
+ ) : (
358
+ <EmptyState />
359
+ )
360
+ )}
361
+ </div>
362
+ </div>
363
+ );
364
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Recommendations Component
3
+ *
4
+ * Displays actionable insights based on usage data, budget status,
5
+ * and service utilisation patterns.
6
+ */
7
+
8
+ import { Lightbulb, AlertTriangle, TrendingUp, Zap } from 'lucide-react';
9
+ import type { ServiceUtilisation, ProjectSummary, BurnRateData } from './types';
10
+
11
+ interface RecommendationsProps {
12
+ services: ServiceUtilisation[];
13
+ projects: ProjectSummary[];
14
+ burnRate: BurnRateData | null;
15
+ }
16
+
17
+ interface Recommendation {
18
+ icon: 'warning' | 'tip' | 'trend' | 'action';
19
+ text: string;
20
+ action?: string;
21
+ link?: string;
22
+ }
23
+
24
+ const ICON_MAP = {
25
+ warning: AlertTriangle,
26
+ tip: Lightbulb,
27
+ trend: TrendingUp,
28
+ action: Zap,
29
+ };
30
+
31
+ const ICON_COLORS = {
32
+ warning: 'text-amber-400',
33
+ tip: 'text-blue-400',
34
+ trend: 'text-emerald-400',
35
+ action: 'text-purple-400',
36
+ };
37
+
38
+ export function Recommendations({ services, projects, burnRate }: RecommendationsProps) {
39
+ const recommendations: Recommendation[] = [];
40
+
41
+ // Check for over-limit services
42
+ const overLimit = services.filter((s) => s.percentage > 100);
43
+ for (const svc of overLimit) {
44
+ recommendations.push({
45
+ icon: 'warning',
46
+ text: `${svc.label} is ${(svc.percentage / 100).toFixed(1)}x over plan limits. Consider upgrading tier or implementing rate limiting.`,
47
+ action: 'Compare Plans',
48
+ link: '/usage/settings',
49
+ });
50
+ }
51
+
52
+ // Check for services approaching limits (>80%)
53
+ const approachingLimit = services.filter((s) => s.percentage > 80 && s.percentage <= 100);
54
+ for (const svc of approachingLimit) {
55
+ recommendations.push({
56
+ icon: 'trend',
57
+ text: `${svc.label} at ${svc.percentage.toFixed(0)}% of plan limit. Monitor closely or consider optimisation.`,
58
+ });
59
+ }
60
+
61
+ // Check for zero-usage projects
62
+ const zeroUsage = projects.filter((p) => p.mtdCost === 0);
63
+ if (zeroUsage.length > 0 && zeroUsage.length < projects.length) {
64
+ recommendations.push({
65
+ icon: 'tip',
66
+ text: `${zeroUsage.length} project(s) have $0 usage this period: ${zeroUsage.map((p) => p.projectName).join(', ')}. Consider reviewing if they're needed.`,
67
+ action: 'Review Inactive',
68
+ });
69
+ }
70
+
71
+ // Check for budget concerns
72
+ if (burnRate?.status === 'red') {
73
+ recommendations.push({
74
+ icon: 'warning',
75
+ text: `Projected spend $${burnRate.projectedMonthlyCost.toFixed(2)} may exceed budget. Review high-cost resources.`,
76
+ action: 'Adjust Budget',
77
+ link: '/usage/settings',
78
+ });
79
+ } else if (burnRate?.status === 'yellow') {
80
+ recommendations.push({
81
+ icon: 'trend',
82
+ text: `Burn rate elevated at $${burnRate.dailyBurnRate.toFixed(2)}/day. ${burnRate.daysRemaining} days remaining in billing period.`,
83
+ });
84
+ }
85
+
86
+ // Check for circuit breaker issues
87
+ const trippedProjects = projects.filter(
88
+ (p) => p.circuitBreakerStatus === 'tripped' || p.circuitBreakerStatus === 'degraded'
89
+ );
90
+ if (trippedProjects.length > 0) {
91
+ recommendations.push({
92
+ icon: 'action',
93
+ text: `${trippedProjects.length} project(s) have tripped circuit breakers: ${trippedProjects.map((p) => p.projectName).join(', ')}. Check for rate limiting issues.`,
94
+ action: 'View Details',
95
+ });
96
+ }
97
+
98
+ // Check for high cost concentration
99
+ if (projects.length > 1) {
100
+ const totalCost = projects.reduce((sum, p) => sum + p.mtdCost, 0);
101
+ if (totalCost > 0) {
102
+ const topProject = projects.reduce((max, p) => (p.mtdCost > max.mtdCost ? p : max));
103
+ const topPct = (topProject.mtdCost / totalCost) * 100;
104
+ if (topPct > 70) {
105
+ recommendations.push({
106
+ icon: 'tip',
107
+ text: `${topProject.projectName} accounts for ${topPct.toFixed(0)}% of total spend. Consider cost optimisation for this project.`,
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ // No recommendations - all is well
114
+ if (recommendations.length === 0) {
115
+ return null;
116
+ }
117
+
118
+ return (
119
+ <section className="mt-6 bg-gray-50/50 dark:bg-slate-900/30 border border-gray-200 dark:border-slate-800 rounded-sm p-4">
120
+ <h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
121
+ <Lightbulb className="w-4 h-4" />
122
+ Recommendations
123
+ </h2>
124
+ <ul className="space-y-3">
125
+ {recommendations.slice(0, 5).map((rec, i) => {
126
+ const Icon = ICON_MAP[rec.icon];
127
+ const iconColor = ICON_COLORS[rec.icon];
128
+
129
+ return (
130
+ <li key={i} className="flex items-start gap-3 text-sm">
131
+ <Icon className={`w-4 h-4 mt-0.5 flex-shrink-0 ${iconColor}`} />
132
+ <span className="flex-1 text-gray-700 dark:text-slate-300">{rec.text}</span>
133
+ {rec.action && (
134
+ <a
135
+ href={rec.link || '#'}
136
+ className="text-blue-400 hover:text-blue-300 text-xs font-mono whitespace-nowrap"
137
+ >
138
+ [{rec.action}]
139
+ </a>
140
+ )}
141
+ </li>
142
+ );
143
+ })}
144
+ </ul>
145
+ </section>
146
+ );
147
+ }
148
+
149
+ export default Recommendations;