@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,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;
|