@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,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Data Transformers
|
|
3
|
+
*
|
|
4
|
+
* Functions to transform API response data into component formats.
|
|
5
|
+
* Part of task-17.9 (Restructure page layout).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UnifiedResource, ResourceType, ResourceStatus } from './types';
|
|
9
|
+
import { CF_PRICING } from '../../lib/cloudflare/costs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transform API usage data into UnifiedResource array for the table.
|
|
13
|
+
* Consolidates all resource types (workers, D1, KV, R2, etc.) into a single list.
|
|
14
|
+
*/
|
|
15
|
+
export function transformToUnifiedResources(
|
|
16
|
+
data: {
|
|
17
|
+
workers?: Array<{
|
|
18
|
+
scriptName: string;
|
|
19
|
+
requests: number;
|
|
20
|
+
cpuTime: number;
|
|
21
|
+
duration: number;
|
|
22
|
+
errors: number;
|
|
23
|
+
}>;
|
|
24
|
+
d1?: Array<{
|
|
25
|
+
databaseId: string;
|
|
26
|
+
databaseName: string;
|
|
27
|
+
rowsRead: number;
|
|
28
|
+
rowsWritten: number;
|
|
29
|
+
queryCount: number;
|
|
30
|
+
}>;
|
|
31
|
+
kv?: Array<{
|
|
32
|
+
namespaceId: string;
|
|
33
|
+
namespaceName: string;
|
|
34
|
+
reads: number;
|
|
35
|
+
writes: number;
|
|
36
|
+
deletes: number;
|
|
37
|
+
lists: number;
|
|
38
|
+
}>;
|
|
39
|
+
r2?: Array<{
|
|
40
|
+
bucketName: string;
|
|
41
|
+
storageBytes: number;
|
|
42
|
+
objectCount: number;
|
|
43
|
+
classAOperations: number;
|
|
44
|
+
classBOperations: number;
|
|
45
|
+
}>;
|
|
46
|
+
vectorize?: Array<{
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
vectorCount: number;
|
|
50
|
+
dimensions: number;
|
|
51
|
+
}>;
|
|
52
|
+
pages?: Array<{
|
|
53
|
+
projectName: string;
|
|
54
|
+
deployments: number;
|
|
55
|
+
requests: number;
|
|
56
|
+
bandwidth: number;
|
|
57
|
+
}>;
|
|
58
|
+
durableObjects?: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
requests: number;
|
|
61
|
+
duration: number;
|
|
62
|
+
storageBytes: number;
|
|
63
|
+
}>;
|
|
64
|
+
aiGateway?: {
|
|
65
|
+
totalRequests: number;
|
|
66
|
+
totalTokens: number;
|
|
67
|
+
cachedRequests: number;
|
|
68
|
+
modelBreakdown: Array<{
|
|
69
|
+
model: string;
|
|
70
|
+
requests: number;
|
|
71
|
+
tokens: number;
|
|
72
|
+
}>;
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
costs: {
|
|
76
|
+
workers: number;
|
|
77
|
+
d1: number;
|
|
78
|
+
kv: number;
|
|
79
|
+
r2: number;
|
|
80
|
+
vectorize: number;
|
|
81
|
+
pages: number;
|
|
82
|
+
queues: number;
|
|
83
|
+
workflows: number;
|
|
84
|
+
durableObjects: number;
|
|
85
|
+
aiGateway: number;
|
|
86
|
+
total: number;
|
|
87
|
+
},
|
|
88
|
+
projectMapping: (name: string) => string
|
|
89
|
+
): UnifiedResource[] {
|
|
90
|
+
const resources: UnifiedResource[] = [];
|
|
91
|
+
|
|
92
|
+
// Transform Workers - calculate cost based on actual requests and CPU time
|
|
93
|
+
if (data.workers) {
|
|
94
|
+
for (const worker of data.workers) {
|
|
95
|
+
// Workers pricing: $0.30 per million requests + $0.02 per million CPU ms
|
|
96
|
+
// Note: $5/mo base cost is account-level, not per-worker
|
|
97
|
+
const requestCost = (worker.requests / 1_000_000) * CF_PRICING.workers.requestsPerMillion;
|
|
98
|
+
const cpuCost = (worker.cpuTime / 1_000_000) * CF_PRICING.workers.cpuMsPerMillion;
|
|
99
|
+
const costPerWorker = requestCost + cpuCost;
|
|
100
|
+
resources.push({
|
|
101
|
+
id: `worker-${worker.scriptName}`,
|
|
102
|
+
name: worker.scriptName,
|
|
103
|
+
type: 'worker' as ResourceType,
|
|
104
|
+
project: projectMapping(worker.scriptName),
|
|
105
|
+
usage: {
|
|
106
|
+
value: worker.requests,
|
|
107
|
+
unit: 'requests',
|
|
108
|
+
formatted: formatNumber(worker.requests),
|
|
109
|
+
},
|
|
110
|
+
costCurrent: costPerWorker,
|
|
111
|
+
costPrior: 0, // Will be filled by comparison data
|
|
112
|
+
costDelta: 0,
|
|
113
|
+
costDeltaPct: null,
|
|
114
|
+
status: getStatusFromThreshold(worker.errors, worker.requests),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Transform D1 databases - calculate cost based on actual rowsRead/rowsWritten
|
|
120
|
+
if (data.d1) {
|
|
121
|
+
for (const db of data.d1) {
|
|
122
|
+
// D1 pricing: $0.001 per billion rows read + $1.00 per million rows written
|
|
123
|
+
const costPerDb =
|
|
124
|
+
(db.rowsRead / 1_000_000_000) * CF_PRICING.d1.rowsReadPerBillion +
|
|
125
|
+
(db.rowsWritten / 1_000_000) * CF_PRICING.d1.rowsWrittenPerMillion;
|
|
126
|
+
resources.push({
|
|
127
|
+
id: `d1-${db.databaseId}`,
|
|
128
|
+
name: db.databaseName || db.databaseId,
|
|
129
|
+
type: 'd1' as ResourceType,
|
|
130
|
+
project: projectMapping(db.databaseName || db.databaseId),
|
|
131
|
+
usage: {
|
|
132
|
+
value: db.rowsRead,
|
|
133
|
+
unit: 'rows read',
|
|
134
|
+
formatted: formatNumber(db.rowsRead),
|
|
135
|
+
},
|
|
136
|
+
costCurrent: costPerDb,
|
|
137
|
+
costPrior: 0,
|
|
138
|
+
costDelta: 0,
|
|
139
|
+
costDeltaPct: null,
|
|
140
|
+
status: 'healthy' as ResourceStatus,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Transform KV namespaces - calculate cost based on actual reads/writes/deletes/lists
|
|
146
|
+
if (data.kv) {
|
|
147
|
+
for (const kv of data.kv) {
|
|
148
|
+
// KV pricing: $0.50/M reads + $5.00/M writes + $5.00/M deletes + $5.00/M lists
|
|
149
|
+
const costPerKv =
|
|
150
|
+
(kv.reads / 1_000_000) * CF_PRICING.kv.readsPerMillion +
|
|
151
|
+
(kv.writes / 1_000_000) * CF_PRICING.kv.writesPerMillion +
|
|
152
|
+
(kv.deletes / 1_000_000) * CF_PRICING.kv.deletesPerMillion +
|
|
153
|
+
(kv.lists / 1_000_000) * CF_PRICING.kv.listsPerMillion;
|
|
154
|
+
const totalOps = kv.reads + kv.writes + kv.deletes + kv.lists;
|
|
155
|
+
resources.push({
|
|
156
|
+
id: `kv-${kv.namespaceId}`,
|
|
157
|
+
name: kv.namespaceName || kv.namespaceId,
|
|
158
|
+
type: 'kv' as ResourceType,
|
|
159
|
+
project: projectMapping(kv.namespaceName || kv.namespaceId),
|
|
160
|
+
usage: {
|
|
161
|
+
value: totalOps,
|
|
162
|
+
unit: 'operations',
|
|
163
|
+
formatted: formatNumber(totalOps),
|
|
164
|
+
},
|
|
165
|
+
costCurrent: costPerKv,
|
|
166
|
+
costPrior: 0,
|
|
167
|
+
costDelta: 0,
|
|
168
|
+
costDeltaPct: null,
|
|
169
|
+
status: 'healthy' as ResourceStatus,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Transform R2 buckets - calculate cost based on actual storage and operations
|
|
175
|
+
if (data.r2) {
|
|
176
|
+
for (const bucket of data.r2) {
|
|
177
|
+
// R2 pricing: $0.015/GB storage + $4.50/M Class A ops + $0.36/M Class B ops
|
|
178
|
+
// Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
|
|
179
|
+
const costPerBucket =
|
|
180
|
+
(bucket.storageBytes / 1_000_000_000) * CF_PRICING.r2.storagePerGbMonth +
|
|
181
|
+
(bucket.classAOperations / 1_000_000) * CF_PRICING.r2.classAPerMillion +
|
|
182
|
+
(bucket.classBOperations / 1_000_000) * CF_PRICING.r2.classBPerMillion;
|
|
183
|
+
resources.push({
|
|
184
|
+
id: `r2-${bucket.bucketName}`,
|
|
185
|
+
name: bucket.bucketName,
|
|
186
|
+
type: 'r2' as ResourceType,
|
|
187
|
+
project: projectMapping(bucket.bucketName),
|
|
188
|
+
usage: {
|
|
189
|
+
value: bucket.storageBytes,
|
|
190
|
+
unit: 'storage',
|
|
191
|
+
formatted: formatBytes(bucket.storageBytes),
|
|
192
|
+
},
|
|
193
|
+
costCurrent: costPerBucket,
|
|
194
|
+
costPrior: 0,
|
|
195
|
+
costDelta: 0,
|
|
196
|
+
costDeltaPct: null,
|
|
197
|
+
status: 'healthy' as ResourceStatus,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Transform Vectorize indexes - calculate cost based on actual dimensions stored
|
|
203
|
+
if (data.vectorize) {
|
|
204
|
+
for (const index of data.vectorize) {
|
|
205
|
+
// Vectorize pricing: $0.01 per million stored dimensions
|
|
206
|
+
const storedDimensions = index.vectorCount * index.dimensions;
|
|
207
|
+
const costPerIndex =
|
|
208
|
+
(storedDimensions / 1_000_000) * CF_PRICING.vectorize.storedDimensionsPerMillion;
|
|
209
|
+
resources.push({
|
|
210
|
+
id: `vectorize-${index.id}`,
|
|
211
|
+
name: index.name || index.id,
|
|
212
|
+
type: 'vectorize' as ResourceType,
|
|
213
|
+
project: projectMapping(index.name || index.id),
|
|
214
|
+
usage: {
|
|
215
|
+
value: index.vectorCount,
|
|
216
|
+
unit: 'vectors',
|
|
217
|
+
formatted: formatNumber(index.vectorCount),
|
|
218
|
+
},
|
|
219
|
+
costCurrent: costPerIndex,
|
|
220
|
+
costPrior: 0,
|
|
221
|
+
costDelta: 0,
|
|
222
|
+
costDeltaPct: null,
|
|
223
|
+
status: 'healthy' as ResourceStatus,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Transform Pages projects - calculate cost based on deployments and bandwidth
|
|
229
|
+
if (data.pages) {
|
|
230
|
+
for (const page of data.pages) {
|
|
231
|
+
// Pages pricing: $0.15 per build after 500 free + $0.02 per GB bandwidth
|
|
232
|
+
// Note: First 500 builds/month are free - assuming passed in usage is billable
|
|
233
|
+
// Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
|
|
234
|
+
const buildCost = page.deployments * CF_PRICING.pages.buildCost;
|
|
235
|
+
const bandwidthCost = (page.bandwidth / 1_000_000_000) * CF_PRICING.pages.bandwidthPerGb;
|
|
236
|
+
const costPerPage = buildCost + bandwidthCost;
|
|
237
|
+
resources.push({
|
|
238
|
+
id: `pages-${page.projectName}`,
|
|
239
|
+
name: page.projectName,
|
|
240
|
+
type: 'pages' as ResourceType,
|
|
241
|
+
project: projectMapping(page.projectName),
|
|
242
|
+
usage: {
|
|
243
|
+
value: page.requests,
|
|
244
|
+
unit: 'requests',
|
|
245
|
+
formatted: formatNumber(page.requests),
|
|
246
|
+
},
|
|
247
|
+
costCurrent: costPerPage,
|
|
248
|
+
costPrior: 0,
|
|
249
|
+
costDelta: 0,
|
|
250
|
+
costDeltaPct: null,
|
|
251
|
+
status: 'healthy' as ResourceStatus,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Transform Durable Objects - calculate cost based on requests, duration, storage
|
|
257
|
+
if (data.durableObjects) {
|
|
258
|
+
for (const obj of data.durableObjects) {
|
|
259
|
+
// DO pricing: $0.15/M requests + $12.50/M GB-seconds + $0.20/GB storage
|
|
260
|
+
// Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
|
|
261
|
+
const requestCost = (obj.requests / 1_000_000) * CF_PRICING.durableObjects.requestsPerMillion;
|
|
262
|
+
const durationCost =
|
|
263
|
+
(obj.duration / 1_000_000) * CF_PRICING.durableObjects.gbSecondsPerMillion;
|
|
264
|
+
const storageCost =
|
|
265
|
+
(obj.storageBytes / 1_000_000_000) * CF_PRICING.durableObjects.storagePerGbMonth;
|
|
266
|
+
const costPerObj = requestCost + durationCost + storageCost;
|
|
267
|
+
resources.push({
|
|
268
|
+
id: `do-${obj.name}`,
|
|
269
|
+
name: obj.name,
|
|
270
|
+
type: 'do' as ResourceType,
|
|
271
|
+
project: projectMapping(obj.name),
|
|
272
|
+
usage: {
|
|
273
|
+
value: obj.requests,
|
|
274
|
+
unit: 'requests',
|
|
275
|
+
formatted: formatNumber(obj.requests),
|
|
276
|
+
},
|
|
277
|
+
costCurrent: costPerObj,
|
|
278
|
+
costPrior: 0,
|
|
279
|
+
costDelta: 0,
|
|
280
|
+
costDeltaPct: null,
|
|
281
|
+
status: 'healthy' as ResourceStatus,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Transform AI Gateway (single entry)
|
|
287
|
+
if (data.aiGateway && data.aiGateway.totalRequests > 0) {
|
|
288
|
+
resources.push({
|
|
289
|
+
id: 'ai-gateway-main',
|
|
290
|
+
name: 'AI Gateway',
|
|
291
|
+
type: 'ai-gateway' as ResourceType,
|
|
292
|
+
project: 'platform', // AI Gateway project attribution
|
|
293
|
+
usage: {
|
|
294
|
+
value: data.aiGateway.totalTokens,
|
|
295
|
+
unit: 'tokens',
|
|
296
|
+
formatted: formatNumber(data.aiGateway.totalTokens),
|
|
297
|
+
},
|
|
298
|
+
costCurrent: costs.aiGateway,
|
|
299
|
+
costPrior: 0,
|
|
300
|
+
costDelta: 0,
|
|
301
|
+
costDeltaPct: null,
|
|
302
|
+
status: 'healthy' as ResourceStatus,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return resources;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Apply comparison data to resources.
|
|
311
|
+
* Updates costPrior, costDelta, and costDeltaPct for each resource.
|
|
312
|
+
*/
|
|
313
|
+
export function applyComparisonData(
|
|
314
|
+
resources: UnifiedResource[],
|
|
315
|
+
priorResources: UnifiedResource[]
|
|
316
|
+
): UnifiedResource[] {
|
|
317
|
+
const priorMap = new Map(priorResources.map((r) => [r.id, r]));
|
|
318
|
+
|
|
319
|
+
return resources.map((resource) => {
|
|
320
|
+
const prior = priorMap.get(resource.id);
|
|
321
|
+
|
|
322
|
+
if (!prior) {
|
|
323
|
+
// New resource - mark as NEW
|
|
324
|
+
return {
|
|
325
|
+
...resource,
|
|
326
|
+
costDeltaPct: 'NEW' as const,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const costDelta = resource.costCurrent - prior.costCurrent;
|
|
331
|
+
// Use $0.01 threshold to avoid extreme percentages from near-zero baselines
|
|
332
|
+
// Cap at 999% for display sanity
|
|
333
|
+
const costDeltaPct =
|
|
334
|
+
prior.costCurrent >= 0.01
|
|
335
|
+
? Math.min((costDelta / prior.costCurrent) * 100, 999)
|
|
336
|
+
: resource.costCurrent > 0
|
|
337
|
+
? 'NEW'
|
|
338
|
+
: 0;
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
...resource,
|
|
342
|
+
costPrior: prior.costCurrent,
|
|
343
|
+
costDelta,
|
|
344
|
+
costDeltaPct:
|
|
345
|
+
typeof costDeltaPct === 'number' ? Math.round(costDeltaPct * 10) / 10 : costDeltaPct,
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Filter resources based on filter state.
|
|
352
|
+
*/
|
|
353
|
+
export function filterResources(
|
|
354
|
+
resources: UnifiedResource[],
|
|
355
|
+
filters: {
|
|
356
|
+
project?: string;
|
|
357
|
+
serviceTypes?: string[];
|
|
358
|
+
searchQuery?: string;
|
|
359
|
+
onlyChanged?: boolean;
|
|
360
|
+
nonZeroCost?: boolean;
|
|
361
|
+
}
|
|
362
|
+
): UnifiedResource[] {
|
|
363
|
+
return resources.filter((resource) => {
|
|
364
|
+
// Project filter
|
|
365
|
+
if (filters.project && filters.project !== 'all' && resource.project !== filters.project) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Service type filter
|
|
370
|
+
if (filters.serviceTypes && filters.serviceTypes.length > 0) {
|
|
371
|
+
if (!filters.serviceTypes.includes(resource.type)) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Search query filter
|
|
377
|
+
if (filters.searchQuery) {
|
|
378
|
+
const query = filters.searchQuery.toLowerCase();
|
|
379
|
+
if (!resource.name.toLowerCase().includes(query)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Only changed filter (>5% change or NEW)
|
|
385
|
+
if (filters.onlyChanged) {
|
|
386
|
+
if (resource.costDeltaPct === 'NEW') return true;
|
|
387
|
+
if (typeof resource.costDeltaPct !== 'number') return false;
|
|
388
|
+
if (Math.abs(resource.costDeltaPct) <= 5) return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Non-zero cost filter
|
|
392
|
+
if (filters.nonZeroCost && resource.costCurrent === 0) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Sort resources by column.
|
|
402
|
+
*/
|
|
403
|
+
export function sortResources(
|
|
404
|
+
resources: UnifiedResource[],
|
|
405
|
+
column: string,
|
|
406
|
+
direction: 'asc' | 'desc'
|
|
407
|
+
): UnifiedResource[] {
|
|
408
|
+
const sorted = [...resources].sort((a, b) => {
|
|
409
|
+
let comparison = 0;
|
|
410
|
+
|
|
411
|
+
switch (column) {
|
|
412
|
+
case 'name':
|
|
413
|
+
comparison = a.name.localeCompare(b.name);
|
|
414
|
+
break;
|
|
415
|
+
case 'type':
|
|
416
|
+
comparison = a.type.localeCompare(b.type);
|
|
417
|
+
break;
|
|
418
|
+
case 'project':
|
|
419
|
+
comparison = a.project.localeCompare(b.project);
|
|
420
|
+
break;
|
|
421
|
+
case 'usage':
|
|
422
|
+
comparison = a.usage.value - b.usage.value;
|
|
423
|
+
break;
|
|
424
|
+
case 'costCurrent':
|
|
425
|
+
comparison = a.costCurrent - b.costCurrent;
|
|
426
|
+
break;
|
|
427
|
+
case 'costDeltaPct': {
|
|
428
|
+
const aPct = a.costDeltaPct === 'NEW' ? Infinity : (a.costDeltaPct ?? -Infinity);
|
|
429
|
+
const bPct = b.costDeltaPct === 'NEW' ? Infinity : (b.costDeltaPct ?? -Infinity);
|
|
430
|
+
comparison = aPct - bPct;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case 'status': {
|
|
434
|
+
const statusOrder = { critical: 4, high: 3, warning: 2, healthy: 1 };
|
|
435
|
+
comparison = statusOrder[a.status] - statusOrder[b.status];
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
default:
|
|
439
|
+
comparison = 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return direction === 'asc' ? comparison : -comparison;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return sorted;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Helper functions
|
|
449
|
+
function formatNumber(num: number): string {
|
|
450
|
+
if (num === undefined || num === null || isNaN(num)) return '0';
|
|
451
|
+
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
|
452
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
|
453
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
454
|
+
return num.toLocaleString();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Format bytes to human-readable string using decimal (SI) units.
|
|
459
|
+
*
|
|
460
|
+
* IMPORTANT: Uses decimal (SI) units because Cloudflare bills in decimal GB:
|
|
461
|
+
* - 1 GB = 1,000,000,000 bytes (decimal/SI - used for billing)
|
|
462
|
+
* - 1 GiB = 1,073,741,824 bytes (binary - NOT used by Cloudflare)
|
|
463
|
+
*/
|
|
464
|
+
function formatBytes(bytes: number): string {
|
|
465
|
+
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
|
|
466
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
|
|
467
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(2)} KB`;
|
|
468
|
+
return `${bytes} B`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getStatusFromThreshold(errors: number, requests: number): ResourceStatus {
|
|
472
|
+
if (requests === 0) return 'healthy';
|
|
473
|
+
const errorRate = (errors / requests) * 100;
|
|
474
|
+
if (errorRate >= 10) return 'critical';
|
|
475
|
+
if (errorRate >= 5) return 'high';
|
|
476
|
+
if (errorRate >= 1) return 'warning';
|
|
477
|
+
return 'healthy';
|
|
478
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlertBanner Component
|
|
3
|
+
*
|
|
4
|
+
* Displays critical alerts for services approaching or exceeding limits.
|
|
5
|
+
* Dismissible alerts are stored in localStorage.
|
|
6
|
+
* Supports collapse/expand for managing alert visibility.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useMemo } from 'react';
|
|
10
|
+
import { AlertTriangle, X, ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react';
|
|
11
|
+
import { clsx } from 'clsx';
|
|
12
|
+
import type { ServiceUtilisation, BurnRateData } from './types';
|
|
13
|
+
|
|
14
|
+
interface AlertBannerProps {
|
|
15
|
+
services: ServiceUtilisation[];
|
|
16
|
+
burnRate: BurnRateData | null;
|
|
17
|
+
onDismiss: (alertId: string) => void;
|
|
18
|
+
dismissedAlerts: Set<string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AlertItem {
|
|
22
|
+
id: string;
|
|
23
|
+
type: 'budget' | 'service';
|
|
24
|
+
title: string;
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function AlertBanner({ services, burnRate, onDismiss, dismissedAlerts }: AlertBannerProps) {
|
|
29
|
+
// Collapsed state - which alerts are collapsed (show title only)
|
|
30
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
31
|
+
// All alerts collapsed state for the header toggle
|
|
32
|
+
const [allCollapsed, setAllCollapsed] = useState(false);
|
|
33
|
+
|
|
34
|
+
// Filter to critical/overage alerts that haven't been dismissed
|
|
35
|
+
const criticalAlerts = services
|
|
36
|
+
.filter((s) => s.status === 'critical' || s.status === 'overage')
|
|
37
|
+
.filter((s) => !dismissedAlerts.has(s.id));
|
|
38
|
+
|
|
39
|
+
// Check for budget alert
|
|
40
|
+
const showBudgetAlert = burnRate?.status === 'red' && !dismissedAlerts.has('budget');
|
|
41
|
+
|
|
42
|
+
// Build unified alert list for count
|
|
43
|
+
const alerts = useMemo(() => {
|
|
44
|
+
const list: AlertItem[] = [];
|
|
45
|
+
|
|
46
|
+
if (showBudgetAlert && burnRate) {
|
|
47
|
+
list.push({
|
|
48
|
+
id: 'budget',
|
|
49
|
+
type: 'budget',
|
|
50
|
+
title: 'BUDGET ALERT: Projected spend exceeds target',
|
|
51
|
+
detail: `Projected: $${burnRate.projectedMonthlyCost.toFixed(2)} | Daily burn: $${burnRate.dailyBurnRate.toFixed(2)}/day | ${burnRate.daysRemaining} days remaining`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const alert of criticalAlerts) {
|
|
56
|
+
list.push({
|
|
57
|
+
id: alert.id,
|
|
58
|
+
type: 'service',
|
|
59
|
+
title: `CRITICAL: ${alert.label} at ${alert.percentage.toFixed(0)}% of limit`,
|
|
60
|
+
detail: `Current: ${alert.current.toLocaleString()} ${alert.unit} / Limit: ${alert.limit.toLocaleString()} ${alert.unit}${alert.costEstimate > 0 ? ` | Est. cost: $${alert.costEstimate.toFixed(2)}` : ''}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return list;
|
|
65
|
+
}, [showBudgetAlert, burnRate, criticalAlerts]);
|
|
66
|
+
|
|
67
|
+
if (alerts.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const toggleCollapse = (id: string) => {
|
|
72
|
+
setCollapsed((prev) => {
|
|
73
|
+
const next = new Set(prev);
|
|
74
|
+
if (next.has(id)) {
|
|
75
|
+
next.delete(id);
|
|
76
|
+
} else {
|
|
77
|
+
next.add(id);
|
|
78
|
+
}
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const toggleAll = () => {
|
|
84
|
+
if (allCollapsed) {
|
|
85
|
+
// Expand all
|
|
86
|
+
setCollapsed(new Set());
|
|
87
|
+
setAllCollapsed(false);
|
|
88
|
+
} else {
|
|
89
|
+
// Collapse all
|
|
90
|
+
setCollapsed(new Set(alerts.map((a) => a.id)));
|
|
91
|
+
setAllCollapsed(true);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="mb-6">
|
|
97
|
+
{/* Alert Header with count and collapse toggle */}
|
|
98
|
+
<div className="flex items-center justify-between mb-2">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
|
101
|
+
<span className="text-xs font-semibold text-rose-300 uppercase tracking-wider">
|
|
102
|
+
{alerts.length} Active Alert{alerts.length !== 1 ? 's' : ''}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={toggleAll}
|
|
108
|
+
className="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 transition-colors px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-slate-800"
|
|
109
|
+
>
|
|
110
|
+
<ChevronsUpDown className="w-3 h-3" />
|
|
111
|
+
{allCollapsed ? 'Expand All' : 'Collapse All'}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Alerts List */}
|
|
116
|
+
<div className="space-y-2">
|
|
117
|
+
{alerts.map((alert) => {
|
|
118
|
+
const isCollapsed = collapsed.has(alert.id);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
key={alert.id}
|
|
123
|
+
className="bg-rose-500/10 border border-rose-500/30 rounded-sm overflow-hidden transition-all"
|
|
124
|
+
>
|
|
125
|
+
{/* Alert Header Row - always visible */}
|
|
126
|
+
<div className="p-3 flex items-center gap-3">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={() => toggleCollapse(alert.id)}
|
|
130
|
+
className="text-rose-400 hover:text-rose-300 p-1 rounded hover:bg-rose-500/20 transition-colors"
|
|
131
|
+
aria-label={isCollapsed ? 'Expand alert' : 'Collapse alert'}
|
|
132
|
+
>
|
|
133
|
+
{isCollapsed ? (
|
|
134
|
+
<ChevronDown className="w-4 h-4" />
|
|
135
|
+
) : (
|
|
136
|
+
<ChevronUp className="w-4 h-4" />
|
|
137
|
+
)}
|
|
138
|
+
</button>
|
|
139
|
+
<AlertTriangle className="w-4 h-4 text-rose-400 flex-shrink-0" />
|
|
140
|
+
<span className="text-rose-200 font-semibold text-sm flex-1 truncate">
|
|
141
|
+
{alert.title}
|
|
142
|
+
</span>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => onDismiss(alert.id)}
|
|
146
|
+
className="text-rose-400 hover:text-rose-300 p-1 rounded hover:bg-rose-500/20 transition-colors"
|
|
147
|
+
aria-label="Dismiss alert"
|
|
148
|
+
>
|
|
149
|
+
<X className="w-4 h-4" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Alert Details - collapsible */}
|
|
154
|
+
<div
|
|
155
|
+
className={clsx(
|
|
156
|
+
'overflow-hidden transition-all duration-200 ease-in-out',
|
|
157
|
+
isCollapsed ? 'max-h-0' : 'max-h-24'
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
<div className="px-4 pb-3 pl-12">
|
|
161
|
+
<p className="text-rose-300/80 text-xs font-mono">{alert.detail}</p>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default AlertBanner;
|