@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,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeroCardsRow Component
|
|
3
|
+
*
|
|
4
|
+
* Row of hero cards showing key metrics:
|
|
5
|
+
* - MTD Spend with burn rate
|
|
6
|
+
* - Plan Utilisation (highest service)
|
|
7
|
+
* - Top Spender (by resource type)
|
|
8
|
+
*
|
|
9
|
+
* React adaptation of Astro hero cards for the unified dashboard.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
import {
|
|
14
|
+
TrendingUp,
|
|
15
|
+
TrendingDown,
|
|
16
|
+
Minus,
|
|
17
|
+
Activity,
|
|
18
|
+
BarChart3,
|
|
19
|
+
Zap,
|
|
20
|
+
Shield,
|
|
21
|
+
Gauge,
|
|
22
|
+
} from 'lucide-react';
|
|
23
|
+
import { clsx } from 'clsx';
|
|
24
|
+
import type {
|
|
25
|
+
BurnRateData,
|
|
26
|
+
ServiceUtilisation,
|
|
27
|
+
ProjectTableRow,
|
|
28
|
+
BillingContextResponse,
|
|
29
|
+
ToolType,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
interface HeroCardsRowProps {
|
|
33
|
+
burnRate: BurnRateData | null;
|
|
34
|
+
services: ServiceUtilisation[];
|
|
35
|
+
projects?: ProjectTableRow[];
|
|
36
|
+
billingContext?: BillingContextResponse | null;
|
|
37
|
+
usageTotals?: Record<ToolType, number>; // Current usage totals by tool
|
|
38
|
+
onMTDSpendClick?: () => void;
|
|
39
|
+
onPlanUtilisationClick?: (serviceId: string) => void;
|
|
40
|
+
onPlanHealthClick?: () => void;
|
|
41
|
+
onTopSpenderClick?: () => void;
|
|
42
|
+
onSystemHealthClick?: (filter: 'critical' | 'warning' | 'all') => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SERVICE_LABELS: Record<string, string> = {
|
|
46
|
+
workers: 'Workers',
|
|
47
|
+
d1: 'D1 Database',
|
|
48
|
+
kv: 'KV Storage',
|
|
49
|
+
r2: 'R2 Storage',
|
|
50
|
+
durableObjects: 'Durable Objects',
|
|
51
|
+
vectorize: 'Vectorize',
|
|
52
|
+
aiGateway: 'AI Gateway',
|
|
53
|
+
workersAI: 'Workers AI',
|
|
54
|
+
queues: 'Queues',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const SERVICE_COLORS: Record<string, string> = {
|
|
58
|
+
workers: 'bg-blue-500',
|
|
59
|
+
d1: 'bg-emerald-500',
|
|
60
|
+
kv: 'bg-amber-500',
|
|
61
|
+
r2: 'bg-violet-500',
|
|
62
|
+
durableObjects: 'bg-pink-500',
|
|
63
|
+
vectorize: 'bg-cyan-500',
|
|
64
|
+
aiGateway: 'bg-indigo-500',
|
|
65
|
+
workersAI: 'bg-red-500',
|
|
66
|
+
queues: 'bg-teal-500',
|
|
67
|
+
'cf-workers': 'bg-blue-500',
|
|
68
|
+
'cf-d1': 'bg-emerald-500',
|
|
69
|
+
'cf-kv': 'bg-amber-500',
|
|
70
|
+
'cf-r2': 'bg-violet-500',
|
|
71
|
+
'cf-do': 'bg-pink-500',
|
|
72
|
+
'cf-vectorize': 'bg-cyan-500',
|
|
73
|
+
'cf-ai-gateway': 'bg-indigo-500',
|
|
74
|
+
'cf-workers-ai': 'bg-red-500',
|
|
75
|
+
'cf-queues': 'bg-teal-500',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function formatCost(cost: number): string {
|
|
79
|
+
if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
|
|
80
|
+
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
81
|
+
if (cost >= 0.01) return `$${cost.toFixed(3)}`;
|
|
82
|
+
return `$${cost.toFixed(4)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatNumber(num: number): string {
|
|
86
|
+
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
|
87
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
88
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
89
|
+
return num.toFixed(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Status thresholds for BulletChart
|
|
94
|
+
*/
|
|
95
|
+
function getStatusFromPercentage(pct: number): 'green' | 'yellow' | 'red' {
|
|
96
|
+
if (pct >= 90) return 'red';
|
|
97
|
+
if (pct >= 75) return 'yellow';
|
|
98
|
+
return 'green';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const STATUS_BAR_COLORS: Record<'green' | 'yellow' | 'red', string> = {
|
|
102
|
+
green: 'bg-emerald-500',
|
|
103
|
+
yellow: 'bg-amber-500',
|
|
104
|
+
red: 'bg-rose-500',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* BulletChart Component - Compact horizontal progress bar with status colours
|
|
109
|
+
*/
|
|
110
|
+
function BulletChart({ label, value, max }: { label: string; value: number; max: number }) {
|
|
111
|
+
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
112
|
+
const status = getStatusFromPercentage(pct);
|
|
113
|
+
const displayPct = pct > 999 ? '>999' : pct.toFixed(0);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="flex items-center gap-2 text-xs">
|
|
117
|
+
<span className="w-16 truncate text-gray-600 dark:text-slate-400" title={label}>
|
|
118
|
+
{label}
|
|
119
|
+
</span>
|
|
120
|
+
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
121
|
+
<div
|
|
122
|
+
className={clsx('h-full rounded-full transition-all', STATUS_BAR_COLORS[status])}
|
|
123
|
+
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<span
|
|
127
|
+
className={clsx(
|
|
128
|
+
'w-10 text-right font-mono font-semibold',
|
|
129
|
+
status === 'red' && 'text-rose-400',
|
|
130
|
+
status === 'yellow' && 'text-amber-400',
|
|
131
|
+
status === 'green' && 'text-emerald-400'
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{displayPct}%
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Plan Health Card - Shows consumption vs Cloudflare Paid Plan allowances
|
|
142
|
+
*/
|
|
143
|
+
function PlanHealthCard({
|
|
144
|
+
billingContext,
|
|
145
|
+
usageTotals,
|
|
146
|
+
onClick,
|
|
147
|
+
}: {
|
|
148
|
+
billingContext: BillingContextResponse | null;
|
|
149
|
+
usageTotals?: Record<ToolType, number>;
|
|
150
|
+
onClick?: () => void;
|
|
151
|
+
}) {
|
|
152
|
+
// Key services to display (Workers, D1, KV, Durable Objects)
|
|
153
|
+
const services = useMemo(() => {
|
|
154
|
+
if (!billingContext || !usageTotals) return [];
|
|
155
|
+
|
|
156
|
+
const allowances = billingContext.allowances;
|
|
157
|
+
const items: Array<{
|
|
158
|
+
key: string;
|
|
159
|
+
label: string;
|
|
160
|
+
value: number;
|
|
161
|
+
max: number;
|
|
162
|
+
unit: string;
|
|
163
|
+
pct: number;
|
|
164
|
+
}> = [];
|
|
165
|
+
|
|
166
|
+
// Workers requests
|
|
167
|
+
if (allowances.workers) {
|
|
168
|
+
const value = usageTotals.workers ?? 0;
|
|
169
|
+
const max = allowances.workers.prorated;
|
|
170
|
+
items.push({
|
|
171
|
+
key: 'workers',
|
|
172
|
+
label: 'Workers',
|
|
173
|
+
value,
|
|
174
|
+
max,
|
|
175
|
+
unit: allowances.workers.unit,
|
|
176
|
+
pct: max > 0 ? (value / max) * 100 : 0,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// D1 (tracks writes as primary metric)
|
|
181
|
+
if (allowances.d1) {
|
|
182
|
+
const value = usageTotals.d1 ?? 0;
|
|
183
|
+
const max = allowances.d1.prorated;
|
|
184
|
+
items.push({
|
|
185
|
+
key: 'd1',
|
|
186
|
+
label: 'D1',
|
|
187
|
+
value,
|
|
188
|
+
max,
|
|
189
|
+
unit: allowances.d1.unit,
|
|
190
|
+
pct: max > 0 ? (value / max) * 100 : 0,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// KV (tracks writes as primary metric)
|
|
195
|
+
if (allowances.kv) {
|
|
196
|
+
const value = usageTotals.kv ?? 0;
|
|
197
|
+
const max = allowances.kv.prorated;
|
|
198
|
+
items.push({
|
|
199
|
+
key: 'kv',
|
|
200
|
+
label: 'KV',
|
|
201
|
+
value,
|
|
202
|
+
max,
|
|
203
|
+
unit: allowances.kv.unit,
|
|
204
|
+
pct: max > 0 ? (value / max) * 100 : 0,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Durable Objects
|
|
209
|
+
if (allowances.durableObjects) {
|
|
210
|
+
const value = usageTotals.durableObjects ?? 0;
|
|
211
|
+
const max = allowances.durableObjects.prorated;
|
|
212
|
+
items.push({
|
|
213
|
+
key: 'durableObjects',
|
|
214
|
+
label: 'DO',
|
|
215
|
+
value,
|
|
216
|
+
max,
|
|
217
|
+
unit: allowances.durableObjects.unit,
|
|
218
|
+
pct: max > 0 ? (value / max) * 100 : 0,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return items;
|
|
223
|
+
}, [billingContext, usageTotals]);
|
|
224
|
+
|
|
225
|
+
// Calculate overall health
|
|
226
|
+
const { overallPct, overallStatus } = useMemo(() => {
|
|
227
|
+
if (services.length === 0) return { overallPct: 0, overallStatus: 'green' as const };
|
|
228
|
+
const maxPct = Math.max(...services.map((s) => s.pct));
|
|
229
|
+
return {
|
|
230
|
+
overallPct: maxPct,
|
|
231
|
+
overallStatus: getStatusFromPercentage(maxPct),
|
|
232
|
+
};
|
|
233
|
+
}, [services]);
|
|
234
|
+
|
|
235
|
+
// Loading state
|
|
236
|
+
if (!billingContext || !usageTotals) {
|
|
237
|
+
return (
|
|
238
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
|
|
239
|
+
<div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
|
|
240
|
+
<div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
|
|
241
|
+
<div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const statusDotColor = {
|
|
247
|
+
green: 'bg-emerald-500',
|
|
248
|
+
yellow: 'bg-amber-500',
|
|
249
|
+
red: 'bg-rose-500',
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
className={clsx(
|
|
255
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
256
|
+
onClick &&
|
|
257
|
+
'cursor-pointer hover:border-cyan-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
258
|
+
)}
|
|
259
|
+
onClick={onClick}
|
|
260
|
+
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
|
261
|
+
role={onClick ? 'button' : undefined}
|
|
262
|
+
tabIndex={onClick ? 0 : undefined}
|
|
263
|
+
>
|
|
264
|
+
{/* Header */}
|
|
265
|
+
<div className="flex items-center justify-between mb-3">
|
|
266
|
+
<div className="flex items-center gap-2">
|
|
267
|
+
<div className="w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
|
268
|
+
<Gauge className="w-4 h-4 text-cyan-400" />
|
|
269
|
+
</div>
|
|
270
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
271
|
+
Plan Health
|
|
272
|
+
</span>
|
|
273
|
+
</div>
|
|
274
|
+
<div className={clsx('w-2.5 h-2.5 rounded-full', statusDotColor[overallStatus])} />
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Main Value - Overall percentage (highest service) */}
|
|
278
|
+
<div className="flex items-baseline gap-2 mb-3">
|
|
279
|
+
<span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
|
|
280
|
+
{overallPct.toFixed(0)}%
|
|
281
|
+
</span>
|
|
282
|
+
<span className="text-xs text-gray-500 dark:text-slate-500">peak usage</span>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Service Breakdown */}
|
|
286
|
+
<div className="space-y-2">
|
|
287
|
+
{services.map((service) => (
|
|
288
|
+
<BulletChart
|
|
289
|
+
key={service.key}
|
|
290
|
+
label={service.label}
|
|
291
|
+
value={service.value}
|
|
292
|
+
max={service.max}
|
|
293
|
+
/>
|
|
294
|
+
))}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* Billing period countdown */}
|
|
298
|
+
{billingContext.countdownText && (
|
|
299
|
+
<p className="text-xs text-gray-500 dark:text-slate-500 mt-3 pt-2 border-t border-gray-200 dark:border-slate-700">
|
|
300
|
+
{billingContext.countdownText}
|
|
301
|
+
</p>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* MTD Spend Card - Shows current month-to-date spend and burn rate
|
|
309
|
+
*/
|
|
310
|
+
function MTDSpendCard({
|
|
311
|
+
burnRate,
|
|
312
|
+
onClick,
|
|
313
|
+
}: {
|
|
314
|
+
burnRate: BurnRateData | null;
|
|
315
|
+
onClick?: () => void;
|
|
316
|
+
}) {
|
|
317
|
+
if (!burnRate) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
|
|
320
|
+
<div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
|
|
321
|
+
<div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
|
|
322
|
+
<div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const trendDirection =
|
|
328
|
+
burnRate.vsLastMonthPct === null ? 'neutral' : burnRate.vsLastMonthPct > 0 ? 'up' : 'down';
|
|
329
|
+
|
|
330
|
+
const statusColors: Record<string, string> = {
|
|
331
|
+
green: 'bg-emerald-500',
|
|
332
|
+
yellow: 'bg-amber-500',
|
|
333
|
+
red: 'bg-rose-500',
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<div
|
|
338
|
+
className={clsx(
|
|
339
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
340
|
+
onClick &&
|
|
341
|
+
'cursor-pointer hover:border-blue-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
342
|
+
)}
|
|
343
|
+
onClick={onClick}
|
|
344
|
+
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
|
345
|
+
role={onClick ? 'button' : undefined}
|
|
346
|
+
tabIndex={onClick ? 0 : undefined}
|
|
347
|
+
>
|
|
348
|
+
{/* Header */}
|
|
349
|
+
<div className="flex items-center justify-between mb-3">
|
|
350
|
+
<div className="flex items-center gap-2">
|
|
351
|
+
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
|
352
|
+
<Activity className="w-4 h-4 text-blue-400" />
|
|
353
|
+
</div>
|
|
354
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
355
|
+
MTD Spend
|
|
356
|
+
</span>
|
|
357
|
+
</div>
|
|
358
|
+
<div className={clsx('w-2.5 h-2.5 rounded-full', statusColors[burnRate.status])} />
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
{/* Main Value */}
|
|
362
|
+
<div className="flex items-baseline gap-2 mb-2">
|
|
363
|
+
<span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
|
|
364
|
+
{formatCost(burnRate.mtdCost)}
|
|
365
|
+
</span>
|
|
366
|
+
{burnRate.vsLastMonthPct !== null && (
|
|
367
|
+
<div
|
|
368
|
+
className={clsx(
|
|
369
|
+
'flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-semibold',
|
|
370
|
+
trendDirection === 'up' && 'bg-rose-500/20 text-rose-400',
|
|
371
|
+
trendDirection === 'down' && 'bg-emerald-500/20 text-emerald-400',
|
|
372
|
+
trendDirection === 'neutral' &&
|
|
373
|
+
'bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
|
374
|
+
)}
|
|
375
|
+
>
|
|
376
|
+
{trendDirection === 'up' && <TrendingUp className="w-3 h-3" />}
|
|
377
|
+
{trendDirection === 'down' && <TrendingDown className="w-3 h-3" />}
|
|
378
|
+
{trendDirection === 'neutral' && <Minus className="w-3 h-3" />}
|
|
379
|
+
<span>{Math.abs(burnRate.vsLastMonthPct).toFixed(1)}%</span>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* Secondary Info */}
|
|
385
|
+
<div className="flex items-center justify-between text-xs">
|
|
386
|
+
<span className="text-gray-500 dark:text-slate-500">
|
|
387
|
+
Projected:{' '}
|
|
388
|
+
<span className="text-gray-600 dark:text-slate-400">
|
|
389
|
+
{formatCost(burnRate.projectedMonthlyCost)}
|
|
390
|
+
</span>
|
|
391
|
+
</span>
|
|
392
|
+
<span className="text-gray-500 dark:text-slate-500">
|
|
393
|
+
{formatCost(burnRate.dailyBurnRate)}/day
|
|
394
|
+
</span>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Plan Utilisation Card - Shows highest service utilisation
|
|
402
|
+
*/
|
|
403
|
+
function PlanUtilisationCard({
|
|
404
|
+
services,
|
|
405
|
+
onClick,
|
|
406
|
+
}: {
|
|
407
|
+
services: ServiceUtilisation[];
|
|
408
|
+
onClick?: (serviceId: string) => void;
|
|
409
|
+
}) {
|
|
410
|
+
const highest = useMemo(() => {
|
|
411
|
+
if (services.length === 0) return null;
|
|
412
|
+
return services.reduce(
|
|
413
|
+
(max, service) => (service.percentage > max.percentage ? service : max),
|
|
414
|
+
services[0]
|
|
415
|
+
);
|
|
416
|
+
}, [services]);
|
|
417
|
+
|
|
418
|
+
if (!highest) {
|
|
419
|
+
return (
|
|
420
|
+
<div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
|
|
421
|
+
<div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
|
|
422
|
+
<div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
|
|
423
|
+
<div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const pct = Math.min(highest.percentage, 100);
|
|
429
|
+
const displayPct = highest.percentage > 999 ? '>999' : highest.percentage.toFixed(0);
|
|
430
|
+
|
|
431
|
+
const statusColor =
|
|
432
|
+
highest.percentage >= 90
|
|
433
|
+
? 'bg-rose-500'
|
|
434
|
+
: highest.percentage >= 75
|
|
435
|
+
? 'bg-orange-500'
|
|
436
|
+
: highest.percentage >= 50
|
|
437
|
+
? 'bg-amber-500'
|
|
438
|
+
: 'bg-emerald-500';
|
|
439
|
+
|
|
440
|
+
const statusLabel =
|
|
441
|
+
highest.percentage >= 90
|
|
442
|
+
? 'Critical'
|
|
443
|
+
: highest.percentage >= 75
|
|
444
|
+
? 'High'
|
|
445
|
+
: highest.percentage >= 50
|
|
446
|
+
? 'Warning'
|
|
447
|
+
: 'Normal';
|
|
448
|
+
|
|
449
|
+
const handleClick = () => onClick?.(highest.id);
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div
|
|
453
|
+
className={clsx(
|
|
454
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
455
|
+
onClick &&
|
|
456
|
+
'cursor-pointer hover:border-purple-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
457
|
+
)}
|
|
458
|
+
onClick={handleClick}
|
|
459
|
+
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
|
460
|
+
role={onClick ? 'button' : undefined}
|
|
461
|
+
tabIndex={onClick ? 0 : undefined}
|
|
462
|
+
>
|
|
463
|
+
{/* Header */}
|
|
464
|
+
<div className="flex items-center gap-2 mb-3">
|
|
465
|
+
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
|
466
|
+
<BarChart3 className="w-4 h-4 text-purple-400" />
|
|
467
|
+
</div>
|
|
468
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
469
|
+
Plan Utilisation
|
|
470
|
+
</span>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{/* Main Value */}
|
|
474
|
+
<div className="flex items-baseline gap-2 mb-2">
|
|
475
|
+
<span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
|
|
476
|
+
{displayPct}%
|
|
477
|
+
</span>
|
|
478
|
+
<span className="text-xs text-gray-500 dark:text-slate-500">{highest.label}</span>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
{/* Progress Bar */}
|
|
482
|
+
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden mb-2">
|
|
483
|
+
<div
|
|
484
|
+
className={clsx('h-full rounded-full transition-all', statusColor)}
|
|
485
|
+
style={{ width: `${pct}%` }}
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
{/* Secondary Info */}
|
|
490
|
+
<div className="flex items-center justify-between text-xs">
|
|
491
|
+
<span className="text-slate-500">
|
|
492
|
+
{formatNumber(highest.current)} / {formatNumber(highest.limit)} {highest.unit}
|
|
493
|
+
</span>
|
|
494
|
+
<span
|
|
495
|
+
className={clsx(
|
|
496
|
+
'font-semibold',
|
|
497
|
+
highest.percentage >= 90 && 'text-rose-400',
|
|
498
|
+
highest.percentage >= 75 && highest.percentage < 90 && 'text-orange-400',
|
|
499
|
+
highest.percentage >= 50 && highest.percentage < 75 && 'text-amber-400',
|
|
500
|
+
highest.percentage < 50 && 'text-emerald-400'
|
|
501
|
+
)}
|
|
502
|
+
>
|
|
503
|
+
{statusLabel}
|
|
504
|
+
</span>
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Top Spender Card - Shows resource type with highest cost
|
|
512
|
+
*/
|
|
513
|
+
function TopSpenderCard({
|
|
514
|
+
services,
|
|
515
|
+
onClick,
|
|
516
|
+
}: {
|
|
517
|
+
services: ServiceUtilisation[];
|
|
518
|
+
onClick?: () => void;
|
|
519
|
+
}) {
|
|
520
|
+
const { topService, breakdown, totalCost } = useMemo(() => {
|
|
521
|
+
const withCost = services
|
|
522
|
+
.filter((s) => s.costEstimate > 0)
|
|
523
|
+
.sort((a, b) => b.costEstimate - a.costEstimate);
|
|
524
|
+
|
|
525
|
+
if (withCost.length === 0) {
|
|
526
|
+
return { topService: null, breakdown: [], totalCost: 0 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const total = withCost.reduce((sum, s) => sum + s.costEstimate, 0);
|
|
530
|
+
return {
|
|
531
|
+
topService: withCost[0],
|
|
532
|
+
breakdown: withCost.slice(0, 4),
|
|
533
|
+
totalCost: total,
|
|
534
|
+
};
|
|
535
|
+
}, [services]);
|
|
536
|
+
|
|
537
|
+
if (!topService) {
|
|
538
|
+
return (
|
|
539
|
+
<div
|
|
540
|
+
className={clsx(
|
|
541
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
542
|
+
onClick &&
|
|
543
|
+
'cursor-pointer hover:border-amber-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
544
|
+
)}
|
|
545
|
+
onClick={onClick}
|
|
546
|
+
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
|
547
|
+
role={onClick ? 'button' : undefined}
|
|
548
|
+
tabIndex={onClick ? 0 : undefined}
|
|
549
|
+
>
|
|
550
|
+
<div className="flex items-center gap-2 mb-3">
|
|
551
|
+
<div className="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center">
|
|
552
|
+
<Zap className="w-4 h-4 text-amber-400" />
|
|
553
|
+
</div>
|
|
554
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
555
|
+
Top Spender
|
|
556
|
+
</span>
|
|
557
|
+
</div>
|
|
558
|
+
<span className="text-xl font-bold font-mono text-gray-900 dark:text-slate-100">$0.00</span>
|
|
559
|
+
<p className="text-xs text-gray-500 dark:text-slate-500 mt-2">No costs recorded</p>
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const pctOfTotal = totalCost > 0 ? (topService.costEstimate / totalCost) * 100 : 0;
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<div
|
|
568
|
+
className={clsx(
|
|
569
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
570
|
+
onClick &&
|
|
571
|
+
'cursor-pointer hover:border-amber-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
572
|
+
)}
|
|
573
|
+
onClick={onClick}
|
|
574
|
+
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
|
575
|
+
role={onClick ? 'button' : undefined}
|
|
576
|
+
tabIndex={onClick ? 0 : undefined}
|
|
577
|
+
>
|
|
578
|
+
{/* Header */}
|
|
579
|
+
<div className="flex items-center gap-2 mb-3">
|
|
580
|
+
<div className="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center">
|
|
581
|
+
<Zap className="w-4 h-4 text-amber-400" />
|
|
582
|
+
</div>
|
|
583
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
584
|
+
Top Spender
|
|
585
|
+
</span>
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
{/* Main Value */}
|
|
589
|
+
<div className="flex items-baseline gap-2 mb-1">
|
|
590
|
+
<span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
|
|
591
|
+
{formatCost(topService.costEstimate)}
|
|
592
|
+
</span>
|
|
593
|
+
<span className="text-xs text-gray-500 dark:text-slate-500">
|
|
594
|
+
{pctOfTotal.toFixed(0)}% of total
|
|
595
|
+
</span>
|
|
596
|
+
</div>
|
|
597
|
+
<p className="text-sm font-medium text-amber-400 mb-3">
|
|
598
|
+
{SERVICE_LABELS[topService.id] || topService.label}
|
|
599
|
+
</p>
|
|
600
|
+
|
|
601
|
+
{/* Breakdown Mini-Bars */}
|
|
602
|
+
<div className="space-y-1.5">
|
|
603
|
+
{breakdown.map((service) => {
|
|
604
|
+
const widthPct = (service.costEstimate / topService.costEstimate) * 100;
|
|
605
|
+
const color = SERVICE_COLORS[service.id] || 'bg-slate-500';
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
<div key={service.id} className="flex items-center gap-2 text-xs">
|
|
609
|
+
<span
|
|
610
|
+
className="w-20 truncate text-gray-500 dark:text-slate-500"
|
|
611
|
+
title={service.label}
|
|
612
|
+
>
|
|
613
|
+
{SERVICE_LABELS[service.id] || service.label}
|
|
614
|
+
</span>
|
|
615
|
+
<div className="flex-1 h-1 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
616
|
+
<div
|
|
617
|
+
className={clsx('h-full rounded-full', color)}
|
|
618
|
+
style={{ width: `${widthPct}%` }}
|
|
619
|
+
/>
|
|
620
|
+
</div>
|
|
621
|
+
<span className="w-12 text-right text-gray-500 dark:text-slate-500">
|
|
622
|
+
{formatCost(service.costEstimate)}
|
|
623
|
+
</span>
|
|
624
|
+
</div>
|
|
625
|
+
);
|
|
626
|
+
})}
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* System Health Card - Shows operational status summary
|
|
634
|
+
*/
|
|
635
|
+
function SystemHealthCard({
|
|
636
|
+
projects,
|
|
637
|
+
onClick,
|
|
638
|
+
}: {
|
|
639
|
+
projects: ProjectTableRow[];
|
|
640
|
+
onClick?: (filter: 'critical' | 'warning' | 'all') => void;
|
|
641
|
+
}) {
|
|
642
|
+
const health = useMemo(() => {
|
|
643
|
+
return projects.reduce(
|
|
644
|
+
(acc, p) => {
|
|
645
|
+
if (p.status === 'STOP') acc.critical++;
|
|
646
|
+
else if (p.status === 'WARN') acc.warning++;
|
|
647
|
+
else acc.healthy++;
|
|
648
|
+
return acc;
|
|
649
|
+
},
|
|
650
|
+
{ critical: 0, warning: 0, healthy: 0 }
|
|
651
|
+
);
|
|
652
|
+
}, [projects]);
|
|
653
|
+
|
|
654
|
+
const totalProjects = projects.length;
|
|
655
|
+
const healthyPct = totalProjects > 0 ? (health.healthy / totalProjects) * 100 : 100;
|
|
656
|
+
|
|
657
|
+
// Determine overall status
|
|
658
|
+
const overallStatus =
|
|
659
|
+
health.critical > 0 ? 'critical' : health.warning > 0 ? 'warning' : 'healthy';
|
|
660
|
+
|
|
661
|
+
const statusColors: Record<string, string> = {
|
|
662
|
+
critical: 'bg-rose-500',
|
|
663
|
+
warning: 'bg-amber-500',
|
|
664
|
+
healthy: 'bg-emerald-500',
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Click filters to the most relevant status
|
|
668
|
+
const handleClick = () => {
|
|
669
|
+
if (health.critical > 0) onClick?.('critical');
|
|
670
|
+
else if (health.warning > 0) onClick?.('warning');
|
|
671
|
+
else onClick?.('all');
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
return (
|
|
675
|
+
<div
|
|
676
|
+
className={clsx(
|
|
677
|
+
'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
|
|
678
|
+
onClick &&
|
|
679
|
+
'cursor-pointer hover:border-emerald-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
|
|
680
|
+
)}
|
|
681
|
+
onClick={handleClick}
|
|
682
|
+
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
|
683
|
+
role={onClick ? 'button' : undefined}
|
|
684
|
+
tabIndex={onClick ? 0 : undefined}
|
|
685
|
+
>
|
|
686
|
+
{/* Header */}
|
|
687
|
+
<div className="flex items-center justify-between mb-3">
|
|
688
|
+
<div className="flex items-center gap-2">
|
|
689
|
+
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
|
690
|
+
<Shield className="w-4 h-4 text-emerald-400" />
|
|
691
|
+
</div>
|
|
692
|
+
<span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
693
|
+
System Health
|
|
694
|
+
</span>
|
|
695
|
+
</div>
|
|
696
|
+
<div className={clsx('w-2.5 h-2.5 rounded-full', statusColors[overallStatus])} />
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
{/* Main Value */}
|
|
700
|
+
<div className="flex items-baseline gap-2 mb-2">
|
|
701
|
+
<span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
|
|
702
|
+
{healthyPct.toFixed(0)}%
|
|
703
|
+
</span>
|
|
704
|
+
<span className="text-xs text-gray-500 dark:text-slate-500">healthy</span>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
{/* Status Breakdown */}
|
|
708
|
+
<div className="flex items-center gap-4 text-xs">
|
|
709
|
+
{health.critical > 0 && (
|
|
710
|
+
<div className="flex items-center gap-1.5">
|
|
711
|
+
<div className="w-2 h-2 rounded-full bg-rose-500" />
|
|
712
|
+
<span className="text-rose-400 font-semibold">{health.critical} Critical</span>
|
|
713
|
+
</div>
|
|
714
|
+
)}
|
|
715
|
+
{health.warning > 0 && (
|
|
716
|
+
<div className="flex items-center gap-1.5">
|
|
717
|
+
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
|
718
|
+
<span className="text-amber-400 font-semibold">{health.warning} Warning</span>
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
<div className="flex items-center gap-1.5">
|
|
722
|
+
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
723
|
+
<span className="text-emerald-400 font-semibold">{health.healthy} Healthy</span>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function HeroCardsRow({
|
|
731
|
+
burnRate,
|
|
732
|
+
services,
|
|
733
|
+
projects = [],
|
|
734
|
+
billingContext,
|
|
735
|
+
usageTotals,
|
|
736
|
+
onMTDSpendClick,
|
|
737
|
+
onPlanUtilisationClick,
|
|
738
|
+
onPlanHealthClick,
|
|
739
|
+
onTopSpenderClick,
|
|
740
|
+
onSystemHealthClick,
|
|
741
|
+
}: HeroCardsRowProps) {
|
|
742
|
+
return (
|
|
743
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
744
|
+
<MTDSpendCard burnRate={burnRate} onClick={onMTDSpendClick} />
|
|
745
|
+
<PlanHealthCard
|
|
746
|
+
billingContext={billingContext ?? null}
|
|
747
|
+
usageTotals={usageTotals}
|
|
748
|
+
onClick={onPlanHealthClick}
|
|
749
|
+
/>
|
|
750
|
+
<PlanUtilisationCard services={services} onClick={onPlanUtilisationClick} />
|
|
751
|
+
<TopSpenderCard services={services} onClick={onTopSpenderClick} />
|
|
752
|
+
<SystemHealthCard projects={projects} onClick={onSystemHealthClick} />
|
|
753
|
+
</div>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export default HeroCardsRow;
|