@littlebearapps/platform-admin-sdk 2.1.0 → 2.2.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 -5
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +121 -3
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
- package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
- package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
- package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
- package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
- package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
- package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
- package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
- package/templates/full/dashboard/src/pages/feedback.astro +365 -0
- package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
- package/templates/full/dashboard/src/pages/map.astro +561 -0
- package/templates/full/dashboard/src/pages/revenue.astro +72 -0
- package/templates/full/dashboard/src/pages/tests.astro +431 -0
- package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
- package/templates/full/scripts/ops/verify-account-total.ts +256 -0
- package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
- package/templates/full/tests/integration/r2-archive.test.ts +108 -0
- package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
- package/templates/shared/.github/workflows/validate-controls.yml +27 -0
- package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
- package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
- package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
- package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
- package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
- package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
- package/templates/shared/dashboard/src/components/Toast.astro +170 -0
- package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
- package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
- package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
- package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
- package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
- package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
- package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
- package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
- package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
- package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
- package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
- package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
- package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
- package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
- package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
- package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
- package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
- package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
- package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
- package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
- package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
- package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
- package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
- package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
- package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
- package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
- package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
- package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
- package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
- package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
- package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
- package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
- package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
- package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
- package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
- package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
- package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
- package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
- package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
- package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
- package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
- package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
- package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
- package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
- package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
- package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
- package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
- package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
- package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
- package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
- package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
- package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
- package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
- package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
- package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
- package/templates/standard/tests/integration/connectors.test.ts +241 -0
- package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
- package/templates/standard/tests/integration/ingestion.test.ts +211 -0
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview Tab Controller
|
|
3
|
+
*
|
|
4
|
+
* Handles Overview tab data loading, card rendering, and table management.
|
|
5
|
+
* Extracted from index.astro for task-22.5 (slim to <300 lines)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { formatNumber, formatBytes, formatCurrency } from './formatters';
|
|
9
|
+
import { getSSRConfig } from './constants';
|
|
10
|
+
import { CF_PRICING } from '../../../lib/cloudflare/costs';
|
|
11
|
+
import { fetchDailyData, syncFromURL, initSubscriptions } from '../state/usageActions';
|
|
12
|
+
|
|
13
|
+
// ========== Types ==========
|
|
14
|
+
|
|
15
|
+
export interface UsageData {
|
|
16
|
+
workers: WorkerData[];
|
|
17
|
+
d1: D1Data[];
|
|
18
|
+
kv: KVData[];
|
|
19
|
+
r2: R2Data[];
|
|
20
|
+
vectorize: VectorizeData[];
|
|
21
|
+
aiGateway: AIGatewayData[];
|
|
22
|
+
pages: PagesData[];
|
|
23
|
+
durableObjects: DurableObjectsData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkerData {
|
|
27
|
+
scriptName: string;
|
|
28
|
+
requests: number;
|
|
29
|
+
errors: number;
|
|
30
|
+
cpuTime?: number;
|
|
31
|
+
wallTime?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface D1Data {
|
|
35
|
+
databaseId: string;
|
|
36
|
+
databaseName: string;
|
|
37
|
+
rowsRead: number;
|
|
38
|
+
rowsWritten: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface KVData {
|
|
42
|
+
namespaceId: string;
|
|
43
|
+
namespaceName: string;
|
|
44
|
+
reads: number;
|
|
45
|
+
writes: number;
|
|
46
|
+
deletes: number;
|
|
47
|
+
lists: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface R2Data {
|
|
51
|
+
bucketName: string;
|
|
52
|
+
storageBytes: number;
|
|
53
|
+
readOps: number;
|
|
54
|
+
writeOps: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface VectorizeData {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
vectorCount: number;
|
|
61
|
+
dimensions: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AIGatewayData {
|
|
65
|
+
name: string;
|
|
66
|
+
requests: number;
|
|
67
|
+
tokens: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PagesData {
|
|
71
|
+
projectName: string;
|
|
72
|
+
productionDeployments: number;
|
|
73
|
+
previewDeployments: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DurableObjectsData {
|
|
77
|
+
requests: number;
|
|
78
|
+
wallTime: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CostBreakdown {
|
|
82
|
+
workers: number;
|
|
83
|
+
d1: number;
|
|
84
|
+
kv: number;
|
|
85
|
+
r2: number;
|
|
86
|
+
vectorize: number;
|
|
87
|
+
aiGateway: number;
|
|
88
|
+
pages: number;
|
|
89
|
+
durableObjects: number;
|
|
90
|
+
total: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ThresholdWarning {
|
|
94
|
+
level: 'warning' | 'high' | 'critical';
|
|
95
|
+
resource: string;
|
|
96
|
+
resourceName: string;
|
|
97
|
+
metric: string;
|
|
98
|
+
current: number;
|
|
99
|
+
limit: number;
|
|
100
|
+
percentage: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ThresholdData {
|
|
104
|
+
warnings: ThresholdWarning[];
|
|
105
|
+
overallLevel: 'healthy' | 'warning' | 'high' | 'critical';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UnifiedResource {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
type: string;
|
|
112
|
+
project: string;
|
|
113
|
+
usage: {
|
|
114
|
+
value: number;
|
|
115
|
+
formatted: string;
|
|
116
|
+
unit: string;
|
|
117
|
+
};
|
|
118
|
+
costCurrent: number;
|
|
119
|
+
costPrior: number;
|
|
120
|
+
costDelta: number;
|
|
121
|
+
costDeltaPct: number | 'NEW' | null;
|
|
122
|
+
status: 'healthy' | 'warning' | 'high' | 'critical';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ========== Constants ==========
|
|
126
|
+
|
|
127
|
+
const TYPE_ICONS: Record<string, string> = {
|
|
128
|
+
worker: '\u2699\uFE0F',
|
|
129
|
+
d1: '\uD83D\uDDC3\uFE0F',
|
|
130
|
+
kv: '\uD83D\uDD11',
|
|
131
|
+
r2: '\uD83D\uDEE2\uFE0F',
|
|
132
|
+
vectorize: '\uD83E\uDDE0',
|
|
133
|
+
pages: '\uD83D\uDCC4',
|
|
134
|
+
do: '\uD83D\uDD17',
|
|
135
|
+
'ai-gateway': '\uD83E\uDD16',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
139
|
+
worker: 'Worker',
|
|
140
|
+
d1: 'D1 Database',
|
|
141
|
+
kv: 'KV Namespace',
|
|
142
|
+
r2: 'R2 Bucket',
|
|
143
|
+
vectorize: 'Vectorize',
|
|
144
|
+
pages: 'Pages',
|
|
145
|
+
do: 'Durable Object',
|
|
146
|
+
'ai-gateway': 'AI Gateway',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const STATUS_COLOURS: Record<string, string> = {
|
|
150
|
+
healthy: '#10B981',
|
|
151
|
+
warning: '#F59E0B',
|
|
152
|
+
high: '#F97316',
|
|
153
|
+
critical: '#EF4444',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ========== Status Helpers ==========
|
|
157
|
+
|
|
158
|
+
export function getStatusFromPercentage(
|
|
159
|
+
percentage: number
|
|
160
|
+
): 'healthy' | 'warning' | 'high' | 'critical' {
|
|
161
|
+
if (percentage >= 90) return 'critical';
|
|
162
|
+
if (percentage >= 75) return 'high';
|
|
163
|
+
if (percentage >= 50) return 'warning';
|
|
164
|
+
return 'healthy';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getStatusFromErrorRate(
|
|
168
|
+
errors: number,
|
|
169
|
+
requests: number
|
|
170
|
+
): 'healthy' | 'warning' | 'high' | 'critical' {
|
|
171
|
+
if (requests === 0) return 'healthy';
|
|
172
|
+
const errorRate = (errors / requests) * 100;
|
|
173
|
+
if (errorRate >= 10) return 'critical';
|
|
174
|
+
if (errorRate >= 5) return 'high';
|
|
175
|
+
if (errorRate >= 1) return 'warning';
|
|
176
|
+
return 'healthy';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ========== Project Identification ==========
|
|
180
|
+
|
|
181
|
+
export function identifyProject(resourceName: string): string {
|
|
182
|
+
const name = resourceName.toLowerCase();
|
|
183
|
+
if (
|
|
184
|
+
name.includes('brand-copilot') ||
|
|
185
|
+
name.includes('brand_copilot') ||
|
|
186
|
+
name.includes('brandcopilot')
|
|
187
|
+
) {
|
|
188
|
+
return 'brand-copilot';
|
|
189
|
+
}
|
|
190
|
+
if (name.includes('scout')) {
|
|
191
|
+
return 'scout';
|
|
192
|
+
}
|
|
193
|
+
if (name.includes('platform') || name.includes('admin')) {
|
|
194
|
+
return 'platform';
|
|
195
|
+
}
|
|
196
|
+
return resourceName;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ========== Workers Breakdown ==========
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Project breakdown data for the WorkersBreakdownTable component
|
|
203
|
+
*/
|
|
204
|
+
interface ProjectBreakdown {
|
|
205
|
+
project: string;
|
|
206
|
+
totalRequests: number;
|
|
207
|
+
totalErrors: number;
|
|
208
|
+
workerCount: number;
|
|
209
|
+
workers: Array<{
|
|
210
|
+
scriptName: string;
|
|
211
|
+
requests: number;
|
|
212
|
+
errors: number;
|
|
213
|
+
cpuTime: number;
|
|
214
|
+
}>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Group workers by project for the breakdown table.
|
|
219
|
+
* Uses identifyProject() to categorize workers.
|
|
220
|
+
*/
|
|
221
|
+
export function buildWorkersBreakdown(workers: WorkerData[]): ProjectBreakdown[] {
|
|
222
|
+
if (!workers || workers.length === 0) return [];
|
|
223
|
+
|
|
224
|
+
// Group workers by project
|
|
225
|
+
const projectMap = new Map<string, ProjectBreakdown>();
|
|
226
|
+
|
|
227
|
+
for (const worker of workers) {
|
|
228
|
+
const project = identifyProject(worker.scriptName);
|
|
229
|
+
|
|
230
|
+
if (!projectMap.has(project)) {
|
|
231
|
+
projectMap.set(project, {
|
|
232
|
+
project,
|
|
233
|
+
totalRequests: 0,
|
|
234
|
+
totalErrors: 0,
|
|
235
|
+
workerCount: 0,
|
|
236
|
+
workers: [],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const breakdown = projectMap.get(project)!;
|
|
241
|
+
breakdown.totalRequests += worker.requests || 0;
|
|
242
|
+
breakdown.totalErrors += worker.errors || 0;
|
|
243
|
+
breakdown.workerCount += 1;
|
|
244
|
+
breakdown.workers.push({
|
|
245
|
+
scriptName: worker.scriptName,
|
|
246
|
+
requests: worker.requests || 0,
|
|
247
|
+
errors: worker.errors || 0,
|
|
248
|
+
cpuTime: worker.cpuTime || 0,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Convert to array and sort by total requests (descending)
|
|
253
|
+
const breakdowns = Array.from(projectMap.values());
|
|
254
|
+
breakdowns.sort((a, b) => b.totalRequests - a.totalRequests);
|
|
255
|
+
|
|
256
|
+
// Sort workers within each project by requests (descending)
|
|
257
|
+
for (const breakdown of breakdowns) {
|
|
258
|
+
breakdown.workers.sort((a, b) => b.requests - a.requests);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return breakdowns;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Update the WorkersBreakdownTable component with current data.
|
|
266
|
+
*/
|
|
267
|
+
export function updateWorkersBreakdownTable(workers: WorkerData[]): void {
|
|
268
|
+
const breakdown = buildWorkersBreakdown(workers);
|
|
269
|
+
|
|
270
|
+
if (typeof window !== 'undefined' && window.updateWorkersBreakdown) {
|
|
271
|
+
window.updateWorkersBreakdown(breakdown);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ========== Format Helpers ==========
|
|
276
|
+
|
|
277
|
+
function formatDeltaPct(deltaPct: number | 'NEW' | null): string {
|
|
278
|
+
if (deltaPct === 'NEW') return 'NEW';
|
|
279
|
+
if (deltaPct === null) return '-';
|
|
280
|
+
const sign = deltaPct >= 0 ? '+' : '';
|
|
281
|
+
return sign + deltaPct.toFixed(1) + '%';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getDeltaClass(deltaPct: number | 'NEW' | null): string {
|
|
285
|
+
if (deltaPct === 'NEW') return 'delta-new';
|
|
286
|
+
if (deltaPct === null) return 'delta-neutral';
|
|
287
|
+
if (deltaPct > 5) return 'delta-up';
|
|
288
|
+
if (deltaPct < -5) return 'delta-down';
|
|
289
|
+
return 'delta-neutral';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Apply comparison data to resources.
|
|
294
|
+
* Updates costPrior, costDelta, and costDeltaPct for each resource.
|
|
295
|
+
*/
|
|
296
|
+
function applyComparisonData(
|
|
297
|
+
resources: UnifiedResource[],
|
|
298
|
+
priorResources: UnifiedResource[]
|
|
299
|
+
): UnifiedResource[] {
|
|
300
|
+
const priorMap = new Map(priorResources.map((r) => [r.id, r]));
|
|
301
|
+
|
|
302
|
+
return resources.map((resource) => {
|
|
303
|
+
const prior = priorMap.get(resource.id);
|
|
304
|
+
|
|
305
|
+
if (!prior) {
|
|
306
|
+
// New resource - mark as NEW
|
|
307
|
+
return {
|
|
308
|
+
...resource,
|
|
309
|
+
costDeltaPct: 'NEW' as const,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const costDelta = resource.costCurrent - prior.costCurrent;
|
|
314
|
+
// Use $0.01 threshold to avoid extreme percentages from near-zero baselines
|
|
315
|
+
// Cap at 999% for display sanity
|
|
316
|
+
const costDeltaPct =
|
|
317
|
+
prior.costCurrent >= 0.01
|
|
318
|
+
? Math.min((costDelta / prior.costCurrent) * 100, 999)
|
|
319
|
+
: resource.costCurrent > 0
|
|
320
|
+
? 'NEW'
|
|
321
|
+
: 0;
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
...resource,
|
|
325
|
+
costPrior: prior.costCurrent,
|
|
326
|
+
costDelta,
|
|
327
|
+
costDeltaPct:
|
|
328
|
+
typeof costDeltaPct === 'number' ? Math.round(costDeltaPct * 10) / 10 : costDeltaPct,
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ========== Transform Functions ==========
|
|
334
|
+
|
|
335
|
+
export function transformToUnifiedResources(
|
|
336
|
+
data: UsageData,
|
|
337
|
+
costs: CostBreakdown,
|
|
338
|
+
projectMapping: (name: string) => string
|
|
339
|
+
): UnifiedResource[] {
|
|
340
|
+
const resources: UnifiedResource[] = [];
|
|
341
|
+
|
|
342
|
+
// Transform Workers - calculate per-resource cost based on actual usage
|
|
343
|
+
if (data.workers && data.workers.length > 0) {
|
|
344
|
+
data.workers.forEach((worker) => {
|
|
345
|
+
// Workers pricing: $0.30 per million requests + $0.02 per million CPU ms
|
|
346
|
+
const requestCost =
|
|
347
|
+
((worker.requests || 0) / 1_000_000) * CF_PRICING.workers.requestsPerMillion;
|
|
348
|
+
const cpuCost = ((worker.cpuTime || 0) / 1_000_000) * CF_PRICING.workers.cpuMsPerMillion;
|
|
349
|
+
const costPerWorker = requestCost + cpuCost;
|
|
350
|
+
resources.push({
|
|
351
|
+
id: `worker-${worker.scriptName}`,
|
|
352
|
+
name: worker.scriptName,
|
|
353
|
+
type: 'worker',
|
|
354
|
+
project: projectMapping(worker.scriptName),
|
|
355
|
+
usage: {
|
|
356
|
+
value: worker.requests || 0,
|
|
357
|
+
unit: 'requests',
|
|
358
|
+
formatted: formatNumber(worker.requests || 0),
|
|
359
|
+
},
|
|
360
|
+
costCurrent: costPerWorker,
|
|
361
|
+
costPrior: 0,
|
|
362
|
+
costDelta: 0,
|
|
363
|
+
costDeltaPct: null,
|
|
364
|
+
status: getStatusFromErrorRate(worker.errors || 0, worker.requests || 0),
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Transform D1 - split into separate rows for reads and writes
|
|
370
|
+
if (data.d1 && data.d1.length > 0) {
|
|
371
|
+
data.d1.forEach((db) => {
|
|
372
|
+
const dbName = db.databaseName || db.databaseId;
|
|
373
|
+
const rowsRead = db.rowsRead || 0;
|
|
374
|
+
const rowsWritten = db.rowsWritten || 0;
|
|
375
|
+
|
|
376
|
+
// D1 pricing: $0.001 per billion rows read
|
|
377
|
+
const rowsReadCost = (rowsRead / 1_000_000_000) * CF_PRICING.d1.rowsReadPerBillion;
|
|
378
|
+
// D1 pricing: $1.00 per million rows written
|
|
379
|
+
const rowsWrittenCost = (rowsWritten / 1_000_000) * CF_PRICING.d1.rowsWrittenPerMillion;
|
|
380
|
+
|
|
381
|
+
// Row for D1 reads
|
|
382
|
+
if (rowsRead > 0) {
|
|
383
|
+
resources.push({
|
|
384
|
+
id: `d1-${db.databaseId}-reads`,
|
|
385
|
+
name: `${dbName} (reads)`,
|
|
386
|
+
type: 'd1',
|
|
387
|
+
project: projectMapping(dbName),
|
|
388
|
+
usage: {
|
|
389
|
+
value: rowsRead,
|
|
390
|
+
unit: 'rows read',
|
|
391
|
+
formatted: formatNumber(rowsRead),
|
|
392
|
+
},
|
|
393
|
+
costCurrent: rowsReadCost,
|
|
394
|
+
costPrior: 0,
|
|
395
|
+
costDelta: 0,
|
|
396
|
+
costDeltaPct: null,
|
|
397
|
+
status: 'healthy',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Row for D1 writes
|
|
402
|
+
if (rowsWritten > 0) {
|
|
403
|
+
resources.push({
|
|
404
|
+
id: `d1-${db.databaseId}-writes`,
|
|
405
|
+
name: `${dbName} (writes)`,
|
|
406
|
+
type: 'd1',
|
|
407
|
+
project: projectMapping(dbName),
|
|
408
|
+
usage: {
|
|
409
|
+
value: rowsWritten,
|
|
410
|
+
unit: 'rows written',
|
|
411
|
+
formatted: formatNumber(rowsWritten),
|
|
412
|
+
},
|
|
413
|
+
costCurrent: rowsWrittenCost,
|
|
414
|
+
costPrior: 0,
|
|
415
|
+
costDelta: 0,
|
|
416
|
+
costDeltaPct: null,
|
|
417
|
+
status: 'healthy',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// If no reads or writes, still show the database with zero
|
|
422
|
+
if (rowsRead === 0 && rowsWritten === 0) {
|
|
423
|
+
resources.push({
|
|
424
|
+
id: `d1-${db.databaseId}`,
|
|
425
|
+
name: dbName,
|
|
426
|
+
type: 'd1',
|
|
427
|
+
project: projectMapping(dbName),
|
|
428
|
+
usage: {
|
|
429
|
+
value: 0,
|
|
430
|
+
unit: 'rows',
|
|
431
|
+
formatted: '0',
|
|
432
|
+
},
|
|
433
|
+
costCurrent: 0,
|
|
434
|
+
costPrior: 0,
|
|
435
|
+
costDelta: 0,
|
|
436
|
+
costDeltaPct: null,
|
|
437
|
+
status: 'healthy',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Transform KV - calculate per-resource cost based on actual usage
|
|
444
|
+
if (data.kv && data.kv.length > 0) {
|
|
445
|
+
data.kv.forEach((ns) => {
|
|
446
|
+
const totalOps = (ns.reads || 0) + (ns.writes || 0) + (ns.deletes || 0) + (ns.lists || 0);
|
|
447
|
+
// KV pricing: $0.50/M reads + $5.00/M writes + $5.00/M deletes + $5.00/M lists
|
|
448
|
+
const costPerKv =
|
|
449
|
+
((ns.reads || 0) / 1_000_000) * CF_PRICING.kv.readsPerMillion +
|
|
450
|
+
((ns.writes || 0) / 1_000_000) * CF_PRICING.kv.writesPerMillion +
|
|
451
|
+
((ns.deletes || 0) / 1_000_000) * CF_PRICING.kv.deletesPerMillion +
|
|
452
|
+
((ns.lists || 0) / 1_000_000) * CF_PRICING.kv.listsPerMillion;
|
|
453
|
+
resources.push({
|
|
454
|
+
id: `kv-${ns.namespaceId}`,
|
|
455
|
+
name: ns.namespaceName || ns.namespaceId,
|
|
456
|
+
type: 'kv',
|
|
457
|
+
project: projectMapping(ns.namespaceName || ns.namespaceId),
|
|
458
|
+
usage: {
|
|
459
|
+
value: totalOps,
|
|
460
|
+
unit: 'operations',
|
|
461
|
+
formatted: formatNumber(totalOps),
|
|
462
|
+
},
|
|
463
|
+
costCurrent: costPerKv,
|
|
464
|
+
costPrior: 0,
|
|
465
|
+
costDelta: 0,
|
|
466
|
+
costDeltaPct: null,
|
|
467
|
+
status: 'healthy',
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Transform R2 - calculate per-resource cost based on actual usage
|
|
473
|
+
if (data.r2 && data.r2.length > 0) {
|
|
474
|
+
data.r2.forEach((bucket) => {
|
|
475
|
+
// R2 pricing: $0.015/GB storage + $4.50/M Class A ops (writes) + $0.36/M Class B ops (reads)
|
|
476
|
+
// Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
|
|
477
|
+
const costPerBucket =
|
|
478
|
+
((bucket.storageBytes || 0) / 1_000_000_000) * CF_PRICING.r2.storagePerGbMonth +
|
|
479
|
+
((bucket.writeOps || 0) / 1_000_000) * CF_PRICING.r2.classAPerMillion +
|
|
480
|
+
((bucket.readOps || 0) / 1_000_000) * CF_PRICING.r2.classBPerMillion;
|
|
481
|
+
resources.push({
|
|
482
|
+
id: `r2-${bucket.bucketName}`,
|
|
483
|
+
name: bucket.bucketName,
|
|
484
|
+
type: 'r2',
|
|
485
|
+
project: projectMapping(bucket.bucketName),
|
|
486
|
+
usage: {
|
|
487
|
+
value: bucket.storageBytes || 0,
|
|
488
|
+
unit: 'storage',
|
|
489
|
+
formatted: formatBytes(bucket.storageBytes || 0),
|
|
490
|
+
},
|
|
491
|
+
costCurrent: costPerBucket,
|
|
492
|
+
costPrior: 0,
|
|
493
|
+
costDelta: 0,
|
|
494
|
+
costDeltaPct: null,
|
|
495
|
+
status: 'healthy',
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Transform Vectorize - calculate per-resource cost based on actual usage
|
|
501
|
+
if (data.vectorize && data.vectorize.length > 0) {
|
|
502
|
+
data.vectorize.forEach((index) => {
|
|
503
|
+
// Vectorize pricing: $0.01 per million stored dimensions
|
|
504
|
+
const storedDimensions = (index.vectorCount || 0) * (index.dimensions || 0);
|
|
505
|
+
const costPerIndex =
|
|
506
|
+
(storedDimensions / 1_000_000) * CF_PRICING.vectorize.storedDimensionsPerMillion;
|
|
507
|
+
resources.push({
|
|
508
|
+
id: `vectorize-${index.id}`,
|
|
509
|
+
name: index.name || index.id,
|
|
510
|
+
type: 'vectorize',
|
|
511
|
+
project: projectMapping(index.name || index.id),
|
|
512
|
+
usage: {
|
|
513
|
+
value: index.vectorCount || 0,
|
|
514
|
+
unit: 'vectors',
|
|
515
|
+
formatted: formatNumber(index.vectorCount || 0),
|
|
516
|
+
},
|
|
517
|
+
costCurrent: costPerIndex,
|
|
518
|
+
costPrior: 0,
|
|
519
|
+
costDelta: 0,
|
|
520
|
+
costDeltaPct: null,
|
|
521
|
+
status: 'healthy',
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Transform AI Gateway
|
|
527
|
+
if (data.aiGateway && data.aiGateway.length > 0) {
|
|
528
|
+
const costPerGateway = (costs.aiGateway || 0) / data.aiGateway.length;
|
|
529
|
+
data.aiGateway.forEach((gateway) => {
|
|
530
|
+
resources.push({
|
|
531
|
+
id: `aigateway-${gateway.name}`,
|
|
532
|
+
name: gateway.name,
|
|
533
|
+
type: 'ai-gateway',
|
|
534
|
+
project: projectMapping(gateway.name),
|
|
535
|
+
usage: {
|
|
536
|
+
value: gateway.requests || 0,
|
|
537
|
+
unit: 'requests',
|
|
538
|
+
formatted: formatNumber(gateway.requests || 0),
|
|
539
|
+
},
|
|
540
|
+
costCurrent: costPerGateway,
|
|
541
|
+
costPrior: 0,
|
|
542
|
+
costDelta: 0,
|
|
543
|
+
costDeltaPct: null,
|
|
544
|
+
status: 'healthy',
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Transform Pages - calculate per-resource cost based on actual usage
|
|
550
|
+
if (data.pages && data.pages.length > 0) {
|
|
551
|
+
data.pages.forEach((page) => {
|
|
552
|
+
const totalDeployments = (page.productionDeployments || 0) + (page.previewDeployments || 0);
|
|
553
|
+
// Pages pricing: $0.15 per build after 500 free
|
|
554
|
+
const costPerPage = totalDeployments * CF_PRICING.pages.buildCost;
|
|
555
|
+
resources.push({
|
|
556
|
+
id: `pages-${page.projectName}`,
|
|
557
|
+
name: page.projectName,
|
|
558
|
+
type: 'pages',
|
|
559
|
+
project: projectMapping(page.projectName),
|
|
560
|
+
usage: {
|
|
561
|
+
value: totalDeployments,
|
|
562
|
+
unit: 'deployments',
|
|
563
|
+
formatted: formatNumber(totalDeployments),
|
|
564
|
+
},
|
|
565
|
+
costCurrent: costPerPage,
|
|
566
|
+
costPrior: 0,
|
|
567
|
+
costDelta: 0,
|
|
568
|
+
costDeltaPct: null,
|
|
569
|
+
status: 'healthy',
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Transform Durable Objects (aggregate)
|
|
575
|
+
if (data.durableObjects && data.durableObjects.requests > 0) {
|
|
576
|
+
resources.push({
|
|
577
|
+
id: 'do-aggregate',
|
|
578
|
+
name: 'Durable Objects (All)',
|
|
579
|
+
type: 'do',
|
|
580
|
+
project: 'platform',
|
|
581
|
+
usage: {
|
|
582
|
+
value: data.durableObjects.requests || 0,
|
|
583
|
+
unit: 'requests',
|
|
584
|
+
formatted: formatNumber(data.durableObjects.requests || 0),
|
|
585
|
+
},
|
|
586
|
+
costCurrent: costs.durableObjects || 0,
|
|
587
|
+
costPrior: 0,
|
|
588
|
+
costDelta: 0,
|
|
589
|
+
costDeltaPct: null,
|
|
590
|
+
status: 'healthy',
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return resources;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ========== DOM Builders ==========
|
|
598
|
+
|
|
599
|
+
export function buildUsageCard(
|
|
600
|
+
label: string,
|
|
601
|
+
icon: string,
|
|
602
|
+
primaryValue: string,
|
|
603
|
+
primaryLabel: string,
|
|
604
|
+
secondaryItems?: Array<{ label: string; value: string }>
|
|
605
|
+
): HTMLElement {
|
|
606
|
+
const card = document.createElement('div');
|
|
607
|
+
card.className = 'usage-card';
|
|
608
|
+
|
|
609
|
+
const header = document.createElement('div');
|
|
610
|
+
header.className = 'usage-card-header';
|
|
611
|
+
|
|
612
|
+
const iconSpan = document.createElement('span');
|
|
613
|
+
iconSpan.className = 'usage-card-icon';
|
|
614
|
+
iconSpan.textContent = icon;
|
|
615
|
+
|
|
616
|
+
const labelSpan = document.createElement('span');
|
|
617
|
+
labelSpan.className = 'usage-card-label';
|
|
618
|
+
labelSpan.textContent = label;
|
|
619
|
+
|
|
620
|
+
header.appendChild(iconSpan);
|
|
621
|
+
header.appendChild(labelSpan);
|
|
622
|
+
|
|
623
|
+
const content = document.createElement('div');
|
|
624
|
+
content.className = 'usage-card-content';
|
|
625
|
+
|
|
626
|
+
const primary = document.createElement('div');
|
|
627
|
+
primary.className = 'usage-card-primary';
|
|
628
|
+
|
|
629
|
+
const valueSpan = document.createElement('span');
|
|
630
|
+
valueSpan.className = 'usage-card-value';
|
|
631
|
+
valueSpan.textContent = primaryValue;
|
|
632
|
+
|
|
633
|
+
const labelText = document.createElement('span');
|
|
634
|
+
labelText.className = 'usage-card-primary-label';
|
|
635
|
+
labelText.textContent = primaryLabel;
|
|
636
|
+
|
|
637
|
+
primary.appendChild(valueSpan);
|
|
638
|
+
primary.appendChild(labelText);
|
|
639
|
+
content.appendChild(primary);
|
|
640
|
+
|
|
641
|
+
if (secondaryItems && secondaryItems.length > 0) {
|
|
642
|
+
const secondary = document.createElement('div');
|
|
643
|
+
secondary.className = 'usage-card-secondary';
|
|
644
|
+
|
|
645
|
+
secondaryItems.forEach((item) => {
|
|
646
|
+
const itemDiv = document.createElement('div');
|
|
647
|
+
itemDiv.className = 'usage-card-secondary-item';
|
|
648
|
+
|
|
649
|
+
const itemLabel = document.createElement('span');
|
|
650
|
+
itemLabel.className = 'secondary-label';
|
|
651
|
+
itemLabel.textContent = item.label;
|
|
652
|
+
|
|
653
|
+
const itemValue = document.createElement('span');
|
|
654
|
+
itemValue.className = 'secondary-value';
|
|
655
|
+
itemValue.textContent = item.value;
|
|
656
|
+
|
|
657
|
+
itemDiv.appendChild(itemLabel);
|
|
658
|
+
itemDiv.appendChild(itemValue);
|
|
659
|
+
secondary.appendChild(itemDiv);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
content.appendChild(secondary);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
card.appendChild(header);
|
|
666
|
+
card.appendChild(content);
|
|
667
|
+
return card;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function buildSparklineCard(config: {
|
|
671
|
+
label: string;
|
|
672
|
+
icon: string;
|
|
673
|
+
primaryValue: string;
|
|
674
|
+
primaryLabel: string;
|
|
675
|
+
sparklineData?: number[];
|
|
676
|
+
trend: 'up' | 'down' | 'stable';
|
|
677
|
+
percentChange?: number;
|
|
678
|
+
status: 'healthy' | 'warning' | 'high' | 'critical';
|
|
679
|
+
secondaryValue?: string;
|
|
680
|
+
secondaryLabel?: string;
|
|
681
|
+
comparisonText?: string;
|
|
682
|
+
cardId: string;
|
|
683
|
+
}): HTMLElement {
|
|
684
|
+
const {
|
|
685
|
+
label,
|
|
686
|
+
icon,
|
|
687
|
+
primaryValue,
|
|
688
|
+
primaryLabel,
|
|
689
|
+
sparklineData,
|
|
690
|
+
trend,
|
|
691
|
+
percentChange,
|
|
692
|
+
status,
|
|
693
|
+
secondaryValue,
|
|
694
|
+
secondaryLabel,
|
|
695
|
+
comparisonText,
|
|
696
|
+
cardId,
|
|
697
|
+
} = config;
|
|
698
|
+
|
|
699
|
+
const card = document.createElement('div');
|
|
700
|
+
card.className = 'sparkline-card';
|
|
701
|
+
card.setAttribute('role', 'region');
|
|
702
|
+
card.setAttribute('aria-label', label);
|
|
703
|
+
card.setAttribute('data-card-id', cardId);
|
|
704
|
+
|
|
705
|
+
const statusColour = STATUS_COLOURS[status] || STATUS_COLOURS.healthy;
|
|
706
|
+
const isErrorMetric = label.toLowerCase().includes('error');
|
|
707
|
+
const trendColour =
|
|
708
|
+
trend === 'stable'
|
|
709
|
+
? '#6B7280'
|
|
710
|
+
: trend === 'up'
|
|
711
|
+
? isErrorMetric
|
|
712
|
+
? '#EF4444'
|
|
713
|
+
: '#10B981'
|
|
714
|
+
: isErrorMetric
|
|
715
|
+
? '#10B981'
|
|
716
|
+
: '#EF4444';
|
|
717
|
+
const trendArrow = trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192';
|
|
718
|
+
|
|
719
|
+
// Header
|
|
720
|
+
const header = document.createElement('div');
|
|
721
|
+
header.className = 'card-header';
|
|
722
|
+
|
|
723
|
+
const headerLeft = document.createElement('div');
|
|
724
|
+
headerLeft.className = 'header-left';
|
|
725
|
+
|
|
726
|
+
const statusDot = document.createElement('span');
|
|
727
|
+
statusDot.className = 'status-dot';
|
|
728
|
+
statusDot.style.backgroundColor = statusColour;
|
|
729
|
+
statusDot.title = 'Status: ' + status;
|
|
730
|
+
|
|
731
|
+
const cardIcon = document.createElement('span');
|
|
732
|
+
cardIcon.className = 'card-icon';
|
|
733
|
+
cardIcon.textContent = icon;
|
|
734
|
+
|
|
735
|
+
const cardLabel = document.createElement('span');
|
|
736
|
+
cardLabel.className = 'card-label';
|
|
737
|
+
cardLabel.textContent = label;
|
|
738
|
+
|
|
739
|
+
headerLeft.appendChild(statusDot);
|
|
740
|
+
headerLeft.appendChild(cardIcon);
|
|
741
|
+
headerLeft.appendChild(cardLabel);
|
|
742
|
+
header.appendChild(headerLeft);
|
|
743
|
+
|
|
744
|
+
if (trend !== 'stable') {
|
|
745
|
+
const trendBadge = document.createElement('div');
|
|
746
|
+
trendBadge.className = 'trend-badge';
|
|
747
|
+
trendBadge.style.color = trendColour;
|
|
748
|
+
trendBadge.style.backgroundColor = trendColour + '15';
|
|
749
|
+
|
|
750
|
+
const arrow = document.createElement('span');
|
|
751
|
+
arrow.className = 'trend-arrow';
|
|
752
|
+
arrow.textContent = trendArrow;
|
|
753
|
+
|
|
754
|
+
const value = document.createElement('span');
|
|
755
|
+
value.className = 'trend-value';
|
|
756
|
+
value.textContent = Math.abs(percentChange || 0).toFixed(1) + '%';
|
|
757
|
+
|
|
758
|
+
trendBadge.appendChild(arrow);
|
|
759
|
+
trendBadge.appendChild(value);
|
|
760
|
+
header.appendChild(trendBadge);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
card.appendChild(header);
|
|
764
|
+
|
|
765
|
+
// Primary
|
|
766
|
+
const primary = document.createElement('div');
|
|
767
|
+
primary.className = 'card-primary';
|
|
768
|
+
|
|
769
|
+
const primaryValueSpan = document.createElement('span');
|
|
770
|
+
primaryValueSpan.className = 'primary-value';
|
|
771
|
+
primaryValueSpan.textContent = primaryValue;
|
|
772
|
+
|
|
773
|
+
const primaryLabelSpan = document.createElement('span');
|
|
774
|
+
primaryLabelSpan.className = 'primary-label';
|
|
775
|
+
primaryLabelSpan.textContent = primaryLabel;
|
|
776
|
+
|
|
777
|
+
primary.appendChild(primaryValueSpan);
|
|
778
|
+
primary.appendChild(primaryLabelSpan);
|
|
779
|
+
card.appendChild(primary);
|
|
780
|
+
|
|
781
|
+
// Sparkline container
|
|
782
|
+
if (sparklineData && sparklineData.length > 1) {
|
|
783
|
+
const sparklineContainer = document.createElement('div');
|
|
784
|
+
sparklineContainer.className = 'sparkline-container';
|
|
785
|
+
|
|
786
|
+
const canvas = document.createElement('canvas');
|
|
787
|
+
canvas.id = 'sparkline-' + cardId;
|
|
788
|
+
canvas.className = 'sparkline-canvas';
|
|
789
|
+
canvas.setAttribute('aria-label', label + ' trend over time');
|
|
790
|
+
|
|
791
|
+
sparklineContainer.appendChild(canvas);
|
|
792
|
+
card.appendChild(sparklineContainer);
|
|
793
|
+
|
|
794
|
+
setTimeout(() => renderSparkline(canvas.id, sparklineData, trendColour), 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Footer
|
|
798
|
+
if (secondaryValue || comparisonText) {
|
|
799
|
+
const footer = document.createElement('div');
|
|
800
|
+
footer.className = 'card-footer';
|
|
801
|
+
|
|
802
|
+
if (secondaryValue) {
|
|
803
|
+
const secondaryMetric = document.createElement('div');
|
|
804
|
+
secondaryMetric.className = 'secondary-metric';
|
|
805
|
+
|
|
806
|
+
const secVal = document.createElement('span');
|
|
807
|
+
secVal.className = 'secondary-value';
|
|
808
|
+
secVal.textContent = secondaryValue;
|
|
809
|
+
secondaryMetric.appendChild(secVal);
|
|
810
|
+
|
|
811
|
+
if (secondaryLabel) {
|
|
812
|
+
const secLabel = document.createElement('span');
|
|
813
|
+
secLabel.className = 'secondary-label';
|
|
814
|
+
secLabel.textContent = secondaryLabel;
|
|
815
|
+
secondaryMetric.appendChild(secLabel);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
footer.appendChild(secondaryMetric);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (comparisonText) {
|
|
822
|
+
const comparison = document.createElement('div');
|
|
823
|
+
comparison.className = 'comparison-text';
|
|
824
|
+
comparison.textContent = comparisonText;
|
|
825
|
+
footer.appendChild(comparison);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
card.appendChild(footer);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return card;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ========== Chart Rendering ==========
|
|
835
|
+
|
|
836
|
+
async function renderSparkline(canvasId: string, data: number[], colour: string): Promise<void> {
|
|
837
|
+
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
|
|
838
|
+
if (!canvas) return;
|
|
839
|
+
|
|
840
|
+
try {
|
|
841
|
+
const { Chart, registerables } = await import('chart.js');
|
|
842
|
+
Chart.register(...registerables);
|
|
843
|
+
|
|
844
|
+
const ctx = canvas.getContext('2d');
|
|
845
|
+
if (!ctx) return;
|
|
846
|
+
|
|
847
|
+
new Chart(ctx, {
|
|
848
|
+
type: 'line',
|
|
849
|
+
data: {
|
|
850
|
+
labels: data.map((_, i) => String(i)),
|
|
851
|
+
datasets: [
|
|
852
|
+
{
|
|
853
|
+
data: data,
|
|
854
|
+
borderColor: colour,
|
|
855
|
+
backgroundColor: colour + '20',
|
|
856
|
+
fill: true,
|
|
857
|
+
tension: 0.4,
|
|
858
|
+
pointRadius: 0,
|
|
859
|
+
borderWidth: 2,
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
},
|
|
863
|
+
options: {
|
|
864
|
+
responsive: true,
|
|
865
|
+
maintainAspectRatio: false,
|
|
866
|
+
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
867
|
+
scales: { x: { display: false }, y: { display: false } },
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
} catch (e) {
|
|
871
|
+
console.error('Failed to render sparkline:', e);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export async function buildCostBreakdownCard(
|
|
876
|
+
container: HTMLElement,
|
|
877
|
+
items: Array<{ label: string; amount: number; colour: string }>
|
|
878
|
+
): Promise<void> {
|
|
879
|
+
container.textContent = '';
|
|
880
|
+
|
|
881
|
+
if (items.length === 0 || items.every((i) => i.amount === 0)) {
|
|
882
|
+
const empty = document.createElement('div');
|
|
883
|
+
empty.className = 'empty-state';
|
|
884
|
+
empty.textContent = 'No cost data available';
|
|
885
|
+
container.appendChild(empty);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const wrapper = document.createElement('div');
|
|
890
|
+
wrapper.className = 'cost-breakdown-wrapper';
|
|
891
|
+
|
|
892
|
+
const chartContent = document.createElement('div');
|
|
893
|
+
chartContent.className = 'cost-breakdown-content';
|
|
894
|
+
|
|
895
|
+
const chartContainer = document.createElement('div');
|
|
896
|
+
chartContainer.className = 'cost-donut-container';
|
|
897
|
+
|
|
898
|
+
const canvas = document.createElement('canvas');
|
|
899
|
+
canvas.id = 'cost-breakdown-chart';
|
|
900
|
+
canvas.className = 'cost-donut-chart';
|
|
901
|
+
chartContainer.appendChild(canvas);
|
|
902
|
+
|
|
903
|
+
chartContent.appendChild(chartContainer);
|
|
904
|
+
|
|
905
|
+
// Legend
|
|
906
|
+
const legend = document.createElement('div');
|
|
907
|
+
legend.className = 'cost-legend';
|
|
908
|
+
|
|
909
|
+
items.forEach((item) => {
|
|
910
|
+
if (item.amount === 0) return;
|
|
911
|
+
const legendItem = document.createElement('div');
|
|
912
|
+
legendItem.className = 'legend-item';
|
|
913
|
+
|
|
914
|
+
const colour = document.createElement('span');
|
|
915
|
+
colour.className = 'legend-colour';
|
|
916
|
+
colour.style.backgroundColor = item.colour;
|
|
917
|
+
|
|
918
|
+
const labelSpan = document.createElement('span');
|
|
919
|
+
labelSpan.className = 'legend-label';
|
|
920
|
+
labelSpan.textContent = item.label;
|
|
921
|
+
|
|
922
|
+
const valueSpan = document.createElement('span');
|
|
923
|
+
valueSpan.className = 'legend-value';
|
|
924
|
+
valueSpan.textContent = '$' + item.amount.toFixed(2);
|
|
925
|
+
|
|
926
|
+
legendItem.appendChild(colour);
|
|
927
|
+
legendItem.appendChild(labelSpan);
|
|
928
|
+
legendItem.appendChild(valueSpan);
|
|
929
|
+
legend.appendChild(legendItem);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
chartContent.appendChild(legend);
|
|
933
|
+
wrapper.appendChild(chartContent);
|
|
934
|
+
container.appendChild(wrapper);
|
|
935
|
+
|
|
936
|
+
// Render chart
|
|
937
|
+
try {
|
|
938
|
+
const { Chart, registerables } = await import('chart.js');
|
|
939
|
+
Chart.register(...registerables);
|
|
940
|
+
|
|
941
|
+
const ctx = canvas.getContext('2d');
|
|
942
|
+
if (!ctx) return;
|
|
943
|
+
|
|
944
|
+
new Chart(ctx, {
|
|
945
|
+
type: 'doughnut',
|
|
946
|
+
data: {
|
|
947
|
+
labels: items.map((i) => i.label),
|
|
948
|
+
datasets: [
|
|
949
|
+
{
|
|
950
|
+
data: items.map((i) => i.amount),
|
|
951
|
+
backgroundColor: items.map((i) => i.colour),
|
|
952
|
+
borderColor: 'transparent',
|
|
953
|
+
borderWidth: 0,
|
|
954
|
+
hoverOffset: 4,
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
},
|
|
958
|
+
options: {
|
|
959
|
+
responsive: true,
|
|
960
|
+
maintainAspectRatio: true,
|
|
961
|
+
cutout: '65%',
|
|
962
|
+
plugins: {
|
|
963
|
+
legend: { display: false },
|
|
964
|
+
tooltip: { enabled: true, backgroundColor: '#1f2937' },
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
} catch (e) {
|
|
969
|
+
console.error('Failed to render donut chart:', e);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ========== Threshold Banner ==========
|
|
974
|
+
|
|
975
|
+
export function buildCompactThresholdBanner(thresholds: ThresholdData): HTMLElement | null {
|
|
976
|
+
const alertWarnings = thresholds.warnings.filter(
|
|
977
|
+
(w) => w.level === 'warning' || w.level === 'high' || w.level === 'critical'
|
|
978
|
+
);
|
|
979
|
+
if (alertWarnings.length === 0) return null;
|
|
980
|
+
|
|
981
|
+
const criticalCount = alertWarnings.filter((w) => w.level === 'critical').length;
|
|
982
|
+
const highCount = alertWarnings.filter((w) => w.level === 'high').length;
|
|
983
|
+
const warningCount = alertWarnings.filter((w) => w.level === 'warning').length;
|
|
984
|
+
|
|
985
|
+
const bannerStyle =
|
|
986
|
+
thresholds.overallLevel === 'critical'
|
|
987
|
+
? 'banner-critical'
|
|
988
|
+
: thresholds.overallLevel === 'high'
|
|
989
|
+
? 'banner-high'
|
|
990
|
+
: 'banner-warning';
|
|
991
|
+
|
|
992
|
+
const banner = document.createElement('div');
|
|
993
|
+
banner.className = 'compact-threshold-banner ' + bannerStyle;
|
|
994
|
+
banner.setAttribute('role', 'alert');
|
|
995
|
+
|
|
996
|
+
const summary = document.createElement('div');
|
|
997
|
+
summary.className = 'banner-summary';
|
|
998
|
+
|
|
999
|
+
const badges = document.createElement('div');
|
|
1000
|
+
badges.className = 'severity-badges';
|
|
1001
|
+
|
|
1002
|
+
if (criticalCount > 0) {
|
|
1003
|
+
badges.appendChild(createSeverityBadge(criticalCount, 'critical', 'Critical'));
|
|
1004
|
+
}
|
|
1005
|
+
if (highCount > 0) {
|
|
1006
|
+
badges.appendChild(createSeverityBadge(highCount, 'high', 'High'));
|
|
1007
|
+
}
|
|
1008
|
+
if (warningCount > 0) {
|
|
1009
|
+
badges.appendChild(createSeverityBadge(warningCount, 'warning', 'Warning'));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
summary.appendChild(badges);
|
|
1013
|
+
|
|
1014
|
+
const bars = document.createElement('div');
|
|
1015
|
+
bars.className = 'threshold-bars';
|
|
1016
|
+
|
|
1017
|
+
alertWarnings.slice(0, 4).forEach((w) => {
|
|
1018
|
+
const container = document.createElement('div');
|
|
1019
|
+
container.className = 'mini-bar-container';
|
|
1020
|
+
container.title = w.resourceName + ': ' + w.percentage.toFixed(0) + '%';
|
|
1021
|
+
|
|
1022
|
+
const bar = document.createElement('div');
|
|
1023
|
+
bar.className = 'mini-bar bar-' + w.level;
|
|
1024
|
+
bar.style.width = Math.min(w.percentage, 100) + '%';
|
|
1025
|
+
|
|
1026
|
+
container.appendChild(bar);
|
|
1027
|
+
bars.appendChild(container);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
if (alertWarnings.length > 4) {
|
|
1031
|
+
const more = document.createElement('span');
|
|
1032
|
+
more.className = 'more-count';
|
|
1033
|
+
more.textContent = '+' + (alertWarnings.length - 4);
|
|
1034
|
+
bars.appendChild(more);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
summary.appendChild(bars);
|
|
1038
|
+
|
|
1039
|
+
const expandBtn = document.createElement('button');
|
|
1040
|
+
expandBtn.className = 'expand-button';
|
|
1041
|
+
expandBtn.setAttribute('aria-expanded', 'false');
|
|
1042
|
+
|
|
1043
|
+
const expandIcon = document.createElement('span');
|
|
1044
|
+
expandIcon.className = 'expand-icon';
|
|
1045
|
+
expandIcon.textContent = '\u25BC';
|
|
1046
|
+
expandBtn.appendChild(expandIcon);
|
|
1047
|
+
|
|
1048
|
+
summary.appendChild(expandBtn);
|
|
1049
|
+
banner.appendChild(summary);
|
|
1050
|
+
|
|
1051
|
+
// Details section
|
|
1052
|
+
const details = document.createElement('div');
|
|
1053
|
+
details.className = 'banner-details';
|
|
1054
|
+
details.hidden = true;
|
|
1055
|
+
|
|
1056
|
+
const grid = document.createElement('div');
|
|
1057
|
+
grid.className = 'details-grid';
|
|
1058
|
+
|
|
1059
|
+
alertWarnings.forEach((w) => {
|
|
1060
|
+
grid.appendChild(createThresholdDetailItem(w));
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
details.appendChild(grid);
|
|
1064
|
+
banner.appendChild(details);
|
|
1065
|
+
|
|
1066
|
+
expandBtn.addEventListener('click', () => {
|
|
1067
|
+
const isExpanded = expandBtn.getAttribute('aria-expanded') === 'true';
|
|
1068
|
+
expandBtn.setAttribute('aria-expanded', String(!isExpanded));
|
|
1069
|
+
details.hidden = isExpanded;
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
return banner;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function createSeverityBadge(count: number, level: string, label: string): HTMLElement {
|
|
1076
|
+
const badge = document.createElement('span');
|
|
1077
|
+
badge.className = 'severity-badge badge-' + level;
|
|
1078
|
+
|
|
1079
|
+
const countSpan = document.createElement('span');
|
|
1080
|
+
countSpan.className = 'badge-count';
|
|
1081
|
+
countSpan.textContent = String(count);
|
|
1082
|
+
|
|
1083
|
+
const labelSpan = document.createElement('span');
|
|
1084
|
+
labelSpan.className = 'badge-label';
|
|
1085
|
+
labelSpan.textContent = label;
|
|
1086
|
+
|
|
1087
|
+
badge.appendChild(countSpan);
|
|
1088
|
+
badge.appendChild(labelSpan);
|
|
1089
|
+
return badge;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function createThresholdDetailItem(w: ThresholdWarning): HTMLElement {
|
|
1093
|
+
const item = document.createElement('div');
|
|
1094
|
+
item.className = 'detail-item detail-' + w.level;
|
|
1095
|
+
|
|
1096
|
+
const header = document.createElement('div');
|
|
1097
|
+
header.className = 'detail-header';
|
|
1098
|
+
|
|
1099
|
+
const dot = document.createElement('span');
|
|
1100
|
+
dot.className = 'detail-dot dot-' + w.level;
|
|
1101
|
+
|
|
1102
|
+
const name = document.createElement('span');
|
|
1103
|
+
name.className = 'detail-name';
|
|
1104
|
+
name.textContent = w.resourceName;
|
|
1105
|
+
|
|
1106
|
+
const level = document.createElement('span');
|
|
1107
|
+
level.className = 'detail-level level-' + w.level;
|
|
1108
|
+
level.textContent = w.level.toUpperCase();
|
|
1109
|
+
|
|
1110
|
+
header.appendChild(dot);
|
|
1111
|
+
header.appendChild(name);
|
|
1112
|
+
header.appendChild(level);
|
|
1113
|
+
item.appendChild(header);
|
|
1114
|
+
|
|
1115
|
+
const metric = document.createElement('div');
|
|
1116
|
+
metric.className = 'detail-metric';
|
|
1117
|
+
metric.textContent = w.metric;
|
|
1118
|
+
item.appendChild(metric);
|
|
1119
|
+
|
|
1120
|
+
const progress = document.createElement('div');
|
|
1121
|
+
progress.className = 'detail-progress';
|
|
1122
|
+
|
|
1123
|
+
const barDiv = document.createElement('div');
|
|
1124
|
+
barDiv.className = 'detail-bar';
|
|
1125
|
+
|
|
1126
|
+
const fill = document.createElement('div');
|
|
1127
|
+
fill.className = 'detail-fill fill-' + w.level;
|
|
1128
|
+
fill.style.width = Math.min(w.percentage, 100) + '%';
|
|
1129
|
+
barDiv.appendChild(fill);
|
|
1130
|
+
|
|
1131
|
+
const values = document.createElement('span');
|
|
1132
|
+
values.className = 'detail-values';
|
|
1133
|
+
values.textContent = formatNumber(w.current) + ' / ' + formatNumber(w.limit);
|
|
1134
|
+
|
|
1135
|
+
const pct = document.createElement('span');
|
|
1136
|
+
pct.className = 'detail-percent';
|
|
1137
|
+
pct.textContent = w.percentage.toFixed(0) + '%';
|
|
1138
|
+
|
|
1139
|
+
progress.appendChild(barDiv);
|
|
1140
|
+
progress.appendChild(values);
|
|
1141
|
+
progress.appendChild(pct);
|
|
1142
|
+
item.appendChild(progress);
|
|
1143
|
+
|
|
1144
|
+
return item;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ========== Resource Table Rendering ==========
|
|
1148
|
+
|
|
1149
|
+
export function renderUnifiedResourceTable(resources: UnifiedResource[]): void {
|
|
1150
|
+
const tbody = document.getElementById('unified-resource-table-tbody');
|
|
1151
|
+
if (!tbody) {
|
|
1152
|
+
console.warn('Could not find unified-resource-table-tbody');
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
tbody.textContent = '';
|
|
1157
|
+
|
|
1158
|
+
if (resources.length === 0) {
|
|
1159
|
+
const emptyRow = document.createElement('tr');
|
|
1160
|
+
emptyRow.className = 'empty-row';
|
|
1161
|
+
|
|
1162
|
+
const emptyCell = document.createElement('td');
|
|
1163
|
+
emptyCell.colSpan = 7;
|
|
1164
|
+
emptyCell.className = 'empty-cell';
|
|
1165
|
+
|
|
1166
|
+
const emptyState = document.createElement('div');
|
|
1167
|
+
emptyState.className = 'empty-state';
|
|
1168
|
+
|
|
1169
|
+
const emptyIcon = document.createElement('span');
|
|
1170
|
+
emptyIcon.className = 'empty-icon';
|
|
1171
|
+
emptyIcon.textContent = '\uD83D\uDCCA';
|
|
1172
|
+
|
|
1173
|
+
const emptyText = document.createElement('span');
|
|
1174
|
+
emptyText.className = 'empty-text';
|
|
1175
|
+
emptyText.textContent = 'No resources found';
|
|
1176
|
+
|
|
1177
|
+
emptyState.appendChild(emptyIcon);
|
|
1178
|
+
emptyState.appendChild(emptyText);
|
|
1179
|
+
emptyCell.appendChild(emptyState);
|
|
1180
|
+
emptyRow.appendChild(emptyCell);
|
|
1181
|
+
tbody.appendChild(emptyRow);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
resources.forEach((resource) => {
|
|
1186
|
+
tbody.appendChild(createResourceRow(resource));
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
updateMobileCards(resources);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function createResourceRow(resource: UnifiedResource): HTMLElement {
|
|
1193
|
+
const row = document.createElement('tr');
|
|
1194
|
+
row.className = 'resource-row';
|
|
1195
|
+
row.dataset.resourceId = resource.id;
|
|
1196
|
+
row.dataset.resourceType = resource.type;
|
|
1197
|
+
row.dataset.expandable = 'true';
|
|
1198
|
+
row.tabIndex = 0;
|
|
1199
|
+
row.setAttribute('role', 'row');
|
|
1200
|
+
|
|
1201
|
+
// Name cell
|
|
1202
|
+
const nameCell = document.createElement('td');
|
|
1203
|
+
nameCell.className = 'cell-name';
|
|
1204
|
+
nameCell.dataset.label = 'Name';
|
|
1205
|
+
const nameContent = document.createElement('div');
|
|
1206
|
+
nameContent.className = 'name-content';
|
|
1207
|
+
const nameSpan = document.createElement('span');
|
|
1208
|
+
nameSpan.className = 'resource-name';
|
|
1209
|
+
nameSpan.textContent = resource.name;
|
|
1210
|
+
nameContent.appendChild(nameSpan);
|
|
1211
|
+
nameCell.appendChild(nameContent);
|
|
1212
|
+
|
|
1213
|
+
// Type cell
|
|
1214
|
+
const typeCell = document.createElement('td');
|
|
1215
|
+
typeCell.className = 'cell-type';
|
|
1216
|
+
typeCell.dataset.label = 'Type';
|
|
1217
|
+
const typeBadge = document.createElement('span');
|
|
1218
|
+
typeBadge.className = 'type-badge';
|
|
1219
|
+
typeBadge.dataset.type = resource.type;
|
|
1220
|
+
const typeIcon = document.createElement('span');
|
|
1221
|
+
typeIcon.className = 'type-icon';
|
|
1222
|
+
typeIcon.textContent = TYPE_ICONS[resource.type] || '\u2753';
|
|
1223
|
+
const typeLabel = document.createElement('span');
|
|
1224
|
+
typeLabel.className = 'type-label';
|
|
1225
|
+
typeLabel.textContent = ' ' + (TYPE_LABELS[resource.type] || resource.type);
|
|
1226
|
+
typeBadge.appendChild(typeIcon);
|
|
1227
|
+
typeBadge.appendChild(typeLabel);
|
|
1228
|
+
typeCell.appendChild(typeBadge);
|
|
1229
|
+
|
|
1230
|
+
// Project cell
|
|
1231
|
+
const projectCell = document.createElement('td');
|
|
1232
|
+
projectCell.className = 'cell-project';
|
|
1233
|
+
projectCell.dataset.label = 'Project';
|
|
1234
|
+
const projectName = document.createElement('span');
|
|
1235
|
+
projectName.className = 'project-name';
|
|
1236
|
+
projectName.textContent = resource.project || 'Unknown';
|
|
1237
|
+
projectCell.appendChild(projectName);
|
|
1238
|
+
|
|
1239
|
+
// Usage cell
|
|
1240
|
+
const usageCell = document.createElement('td');
|
|
1241
|
+
usageCell.className = 'cell-usage';
|
|
1242
|
+
usageCell.style.textAlign = 'right';
|
|
1243
|
+
usageCell.dataset.label = 'Usage';
|
|
1244
|
+
usageCell.dataset.value = String(resource.usage.value);
|
|
1245
|
+
const usageContent = document.createElement('div');
|
|
1246
|
+
usageContent.className = 'usage-content';
|
|
1247
|
+
const usageValue = document.createElement('span');
|
|
1248
|
+
usageValue.className = 'usage-value';
|
|
1249
|
+
usageValue.textContent = resource.usage.formatted;
|
|
1250
|
+
const usageUnit = document.createElement('span');
|
|
1251
|
+
usageUnit.className = 'usage-unit';
|
|
1252
|
+
usageUnit.textContent = ' ' + resource.usage.unit;
|
|
1253
|
+
usageContent.appendChild(usageValue);
|
|
1254
|
+
usageContent.appendChild(usageUnit);
|
|
1255
|
+
usageCell.appendChild(usageContent);
|
|
1256
|
+
|
|
1257
|
+
// Cost cell
|
|
1258
|
+
const costCell = document.createElement('td');
|
|
1259
|
+
costCell.className = 'cell-cost';
|
|
1260
|
+
costCell.style.textAlign = 'right';
|
|
1261
|
+
costCell.dataset.label = 'Cost';
|
|
1262
|
+
costCell.dataset.value = String(resource.costCurrent);
|
|
1263
|
+
const costValue = document.createElement('span');
|
|
1264
|
+
costValue.className = 'cost-value';
|
|
1265
|
+
costValue.textContent = formatCurrency(resource.costCurrent);
|
|
1266
|
+
costCell.appendChild(costValue);
|
|
1267
|
+
|
|
1268
|
+
// Change cell
|
|
1269
|
+
const deltaCell = document.createElement('td');
|
|
1270
|
+
deltaCell.className = 'cell-delta';
|
|
1271
|
+
deltaCell.style.textAlign = 'right';
|
|
1272
|
+
deltaCell.dataset.label = 'Change';
|
|
1273
|
+
deltaCell.dataset.value = String(resource.costDeltaPct);
|
|
1274
|
+
const deltaValue = document.createElement('span');
|
|
1275
|
+
deltaValue.className = 'delta-value ' + getDeltaClass(resource.costDeltaPct);
|
|
1276
|
+
deltaValue.textContent = formatDeltaPct(resource.costDeltaPct);
|
|
1277
|
+
deltaCell.appendChild(deltaValue);
|
|
1278
|
+
|
|
1279
|
+
// Status cell
|
|
1280
|
+
const statusCell = document.createElement('td');
|
|
1281
|
+
statusCell.className = 'cell-status';
|
|
1282
|
+
statusCell.dataset.label = 'Status';
|
|
1283
|
+
const statusIndicator = document.createElement('span');
|
|
1284
|
+
statusIndicator.className = 'status-indicator';
|
|
1285
|
+
statusIndicator.dataset.status = resource.status;
|
|
1286
|
+
statusIndicator.style.backgroundColor = STATUS_COLOURS[resource.status] || '#888';
|
|
1287
|
+
statusIndicator.title = `Status: ${resource.status}`;
|
|
1288
|
+
statusCell.appendChild(statusIndicator);
|
|
1289
|
+
|
|
1290
|
+
row.appendChild(nameCell);
|
|
1291
|
+
row.appendChild(typeCell);
|
|
1292
|
+
row.appendChild(projectCell);
|
|
1293
|
+
row.appendChild(usageCell);
|
|
1294
|
+
row.appendChild(costCell);
|
|
1295
|
+
row.appendChild(deltaCell);
|
|
1296
|
+
row.appendChild(statusCell);
|
|
1297
|
+
|
|
1298
|
+
return row;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function updateMobileCards(resources: UnifiedResource[]): void {
|
|
1302
|
+
const cardsContainer = document.getElementById('unified-resource-table-cards');
|
|
1303
|
+
if (!cardsContainer) return;
|
|
1304
|
+
|
|
1305
|
+
cardsContainer.textContent = '';
|
|
1306
|
+
|
|
1307
|
+
resources.forEach((resource) => {
|
|
1308
|
+
const card = document.createElement('div');
|
|
1309
|
+
card.className = 'resource-card';
|
|
1310
|
+
card.dataset.resourceId = resource.id;
|
|
1311
|
+
|
|
1312
|
+
const cardHeader = document.createElement('div');
|
|
1313
|
+
cardHeader.className = 'card-header';
|
|
1314
|
+
|
|
1315
|
+
const cardName = document.createElement('span');
|
|
1316
|
+
cardName.className = 'card-name';
|
|
1317
|
+
cardName.textContent = resource.name;
|
|
1318
|
+
|
|
1319
|
+
const cardType = document.createElement('span');
|
|
1320
|
+
cardType.className = 'card-type';
|
|
1321
|
+
cardType.textContent = TYPE_LABELS[resource.type] || resource.type;
|
|
1322
|
+
|
|
1323
|
+
cardHeader.appendChild(cardName);
|
|
1324
|
+
cardHeader.appendChild(cardType);
|
|
1325
|
+
card.appendChild(cardHeader);
|
|
1326
|
+
|
|
1327
|
+
const cardBody = document.createElement('div');
|
|
1328
|
+
cardBody.className = 'card-body';
|
|
1329
|
+
|
|
1330
|
+
const rows = [
|
|
1331
|
+
{ label: 'Project', value: resource.project || 'Unknown' },
|
|
1332
|
+
{ label: 'Usage', value: resource.usage.formatted + ' ' + resource.usage.unit },
|
|
1333
|
+
{ label: 'Cost', value: formatCurrency(resource.costCurrent) },
|
|
1334
|
+
];
|
|
1335
|
+
|
|
1336
|
+
rows.forEach((row) => {
|
|
1337
|
+
const rowDiv = document.createElement('div');
|
|
1338
|
+
rowDiv.className = 'card-row';
|
|
1339
|
+
|
|
1340
|
+
const rowLabel = document.createElement('span');
|
|
1341
|
+
rowLabel.className = 'row-label';
|
|
1342
|
+
rowLabel.textContent = row.label;
|
|
1343
|
+
|
|
1344
|
+
const rowValue = document.createElement('span');
|
|
1345
|
+
rowValue.className = 'row-value';
|
|
1346
|
+
rowValue.textContent = row.value;
|
|
1347
|
+
|
|
1348
|
+
rowDiv.appendChild(rowLabel);
|
|
1349
|
+
rowDiv.appendChild(rowValue);
|
|
1350
|
+
cardBody.appendChild(rowDiv);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
card.appendChild(cardBody);
|
|
1354
|
+
cardsContainer.appendChild(card);
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ========== Table Sorting ==========
|
|
1359
|
+
|
|
1360
|
+
export function initSortableHeaders(): void {
|
|
1361
|
+
document.querySelectorAll('.sortable-table th.sortable').forEach((th) => {
|
|
1362
|
+
th.addEventListener('click', () => {
|
|
1363
|
+
const table = th.closest('table');
|
|
1364
|
+
if (!table) return;
|
|
1365
|
+
|
|
1366
|
+
const column = th.getAttribute('data-sort');
|
|
1367
|
+
const dataType = th.getAttribute('data-type') || 'string';
|
|
1368
|
+
if (!column) return;
|
|
1369
|
+
|
|
1370
|
+
sortTable(table, column, dataType);
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function sortTable(table: HTMLTableElement, column: string, dataType: string): void {
|
|
1376
|
+
const tbody = table.querySelector('tbody');
|
|
1377
|
+
if (!tbody) return;
|
|
1378
|
+
|
|
1379
|
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
1380
|
+
const th = table.querySelector(`th[data-sort="${column}"]`);
|
|
1381
|
+
const currentOrder = th?.getAttribute('data-order') || 'none';
|
|
1382
|
+
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
|
|
1383
|
+
|
|
1384
|
+
// Reset all headers
|
|
1385
|
+
table.querySelectorAll('th').forEach((h) => h.setAttribute('data-order', 'none'));
|
|
1386
|
+
th?.setAttribute('data-order', newOrder);
|
|
1387
|
+
|
|
1388
|
+
rows.sort((a, b) => {
|
|
1389
|
+
const aCell =
|
|
1390
|
+
a.querySelector(`td[data-label="${column}"]`) || a.cells[getColumnIndex(table, column)];
|
|
1391
|
+
const bCell =
|
|
1392
|
+
b.querySelector(`td[data-label="${column}"]`) || b.cells[getColumnIndex(table, column)];
|
|
1393
|
+
|
|
1394
|
+
const aVal = aCell?.getAttribute('data-value') || aCell?.textContent?.trim() || '';
|
|
1395
|
+
const bVal = bCell?.getAttribute('data-value') || bCell?.textContent?.trim() || '';
|
|
1396
|
+
|
|
1397
|
+
if (dataType === 'number') {
|
|
1398
|
+
const aNum = parseFloat(aVal) || 0;
|
|
1399
|
+
const bNum = parseFloat(bVal) || 0;
|
|
1400
|
+
return newOrder === 'asc' ? aNum - bNum : bNum - aNum;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
return newOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
rows.forEach((row) => tbody.appendChild(row));
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function getColumnIndex(table: HTMLTableElement, column: string): number {
|
|
1410
|
+
const headers = Array.from(table.querySelectorAll('th'));
|
|
1411
|
+
return headers.findIndex((h) => h.getAttribute('data-sort') === column);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// ========== Responsive Handling ==========
|
|
1415
|
+
|
|
1416
|
+
export function handleResponsiveSwitch(): void {
|
|
1417
|
+
const tableView = document.getElementById('unified-resource-table');
|
|
1418
|
+
const cardsView = document.getElementById('unified-resource-table-cards');
|
|
1419
|
+
|
|
1420
|
+
if (!tableView || !cardsView) return;
|
|
1421
|
+
|
|
1422
|
+
if (window.innerWidth < 768) {
|
|
1423
|
+
tableView.style.display = 'none';
|
|
1424
|
+
cardsView.style.display = 'block';
|
|
1425
|
+
} else {
|
|
1426
|
+
tableView.style.display = 'block';
|
|
1427
|
+
cardsView.style.display = 'none';
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ========== Data Loading ==========
|
|
1432
|
+
|
|
1433
|
+
export async function loadUsageData(nocache = false): Promise<void> {
|
|
1434
|
+
const { period, project, compare } = getSSRConfig();
|
|
1435
|
+
|
|
1436
|
+
// Show loading state - handle both overview section and main page containers
|
|
1437
|
+
const loadingContainer = document.getElementById('overview-loading');
|
|
1438
|
+
const contentContainer = document.getElementById('overview-content');
|
|
1439
|
+
const mainLoadingState = document.getElementById('loading-state');
|
|
1440
|
+
const mainDataContainer = document.getElementById('data-container');
|
|
1441
|
+
const mainErrorBanner = document.getElementById('error-banner');
|
|
1442
|
+
|
|
1443
|
+
if (loadingContainer) loadingContainer.style.display = 'block';
|
|
1444
|
+
if (contentContainer) contentContainer.style.display = 'none';
|
|
1445
|
+
|
|
1446
|
+
try {
|
|
1447
|
+
const params = new URLSearchParams({ period, project });
|
|
1448
|
+
|
|
1449
|
+
// Add nocache parameter if requested (bypasses KV cache)
|
|
1450
|
+
if (nocache) {
|
|
1451
|
+
params.set('nocache', 'true');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// If comparison mode is active, fetch from /api/usage/compare instead
|
|
1455
|
+
if (compare !== 'none') {
|
|
1456
|
+
params.set('compare', compare);
|
|
1457
|
+
// Include credentials to pass Cloudflare Access JWT cookie
|
|
1458
|
+
const response = await fetch(`/api/usage/compare?${params}`, {
|
|
1459
|
+
credentials: 'include',
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
if (!response.ok) {
|
|
1463
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const json = await response.json();
|
|
1467
|
+
|
|
1468
|
+
if (!json.success) {
|
|
1469
|
+
throw new Error(json.error || 'Invalid response');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Compare API returns { current: { data, costs, ... }, prior: { data, costs, ... } }
|
|
1473
|
+
renderOverviewData({
|
|
1474
|
+
usage: json.current.data,
|
|
1475
|
+
costs: json.current.costs,
|
|
1476
|
+
thresholds: json.thresholds || { alerts: [], counts: { low: 0, medium: 0, high: 0 } },
|
|
1477
|
+
priorUsage: json.prior.data,
|
|
1478
|
+
priorCosts: json.prior.costs,
|
|
1479
|
+
});
|
|
1480
|
+
} else {
|
|
1481
|
+
// Standard usage fetch (no comparison)
|
|
1482
|
+
// Include credentials to pass Cloudflare Access JWT cookie
|
|
1483
|
+
const response = await fetch(`/api/usage?${params}`, {
|
|
1484
|
+
credentials: 'include',
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
if (!response.ok) {
|
|
1488
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const json = await response.json();
|
|
1492
|
+
|
|
1493
|
+
if (!json.success) {
|
|
1494
|
+
throw new Error(json.error || 'Invalid response');
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// API returns { data: UsageData, costs: CostBreakdown, thresholds: ThresholdData }
|
|
1498
|
+
renderOverviewData({
|
|
1499
|
+
usage: json.data,
|
|
1500
|
+
costs: json.costs,
|
|
1501
|
+
thresholds: json.thresholds,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Hide main page loading state, show data container
|
|
1506
|
+
if (mainLoadingState) mainLoadingState.style.display = 'none';
|
|
1507
|
+
if (mainDataContainer) mainDataContainer.style.display = 'block';
|
|
1508
|
+
if (mainErrorBanner) mainErrorBanner.style.display = 'none';
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
console.error('Failed to load usage data:', error);
|
|
1511
|
+
const errorContainer = document.getElementById('overview-error');
|
|
1512
|
+
if (errorContainer) {
|
|
1513
|
+
errorContainer.style.display = 'block';
|
|
1514
|
+
errorContainer.textContent =
|
|
1515
|
+
'Failed to load usage data: ' + (error instanceof Error ? error.message : 'Unknown error');
|
|
1516
|
+
}
|
|
1517
|
+
// Show main page error banner
|
|
1518
|
+
if (mainErrorBanner) {
|
|
1519
|
+
mainErrorBanner.style.display = 'block';
|
|
1520
|
+
const errorMsg = document.getElementById('error-message');
|
|
1521
|
+
if (errorMsg) {
|
|
1522
|
+
errorMsg.textContent = error instanceof Error ? error.message : 'Unknown error';
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (mainLoadingState) mainLoadingState.style.display = 'none';
|
|
1526
|
+
} finally {
|
|
1527
|
+
if (loadingContainer) loadingContainer.style.display = 'none';
|
|
1528
|
+
if (contentContainer) contentContainer.style.display = 'block';
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function renderOverviewData(data: {
|
|
1533
|
+
usage: UsageData;
|
|
1534
|
+
costs: CostBreakdown;
|
|
1535
|
+
thresholds: ThresholdData;
|
|
1536
|
+
priorUsage?: UsageData;
|
|
1537
|
+
priorCosts?: CostBreakdown;
|
|
1538
|
+
}): void {
|
|
1539
|
+
const { usage, costs, thresholds, priorUsage, priorCosts } = data;
|
|
1540
|
+
|
|
1541
|
+
// Render threshold banner
|
|
1542
|
+
const bannerContainer = document.getElementById('threshold-banner-container');
|
|
1543
|
+
if (bannerContainer) {
|
|
1544
|
+
bannerContainer.textContent = '';
|
|
1545
|
+
const banner = buildCompactThresholdBanner(thresholds);
|
|
1546
|
+
if (banner) {
|
|
1547
|
+
bannerContainer.appendChild(banner);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Render cost breakdown chart (Bug fix: ID was 'cost-breakdown-container', corrected to 'cost-donut-container')
|
|
1552
|
+
const costContainer = document.getElementById('cost-donut-container');
|
|
1553
|
+
if (costContainer) {
|
|
1554
|
+
const items = [
|
|
1555
|
+
{ label: 'Workers', amount: costs.workers || 0, colour: '#3B82F6' },
|
|
1556
|
+
{ label: 'D1', amount: costs.d1 || 0, colour: '#10B981' },
|
|
1557
|
+
{ label: 'KV', amount: costs.kv || 0, colour: '#F59E0B' },
|
|
1558
|
+
{ label: 'R2', amount: costs.r2 || 0, colour: '#EF4444' },
|
|
1559
|
+
{ label: 'Vectorize', amount: costs.vectorize || 0, colour: '#8B5CF6' },
|
|
1560
|
+
{ label: 'AI Gateway', amount: costs.aiGateway || 0, colour: '#EC4899' },
|
|
1561
|
+
{ label: 'Pages', amount: costs.pages || 0, colour: '#6366F1' },
|
|
1562
|
+
{ label: 'Durable Objects', amount: costs.durableObjects || 0, colour: '#14B8A6' },
|
|
1563
|
+
].filter((i) => i.amount > 0);
|
|
1564
|
+
|
|
1565
|
+
buildCostBreakdownCard(costContainer, items);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Update total cost display (Bug fix: ID was 'total-cost-value', corrected to 'cost-value')
|
|
1569
|
+
const totalCostEl = document.getElementById('cost-value');
|
|
1570
|
+
if (totalCostEl) {
|
|
1571
|
+
totalCostEl.textContent = formatCurrency(costs.total || 0);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Transform current resources
|
|
1575
|
+
let resources = transformToUnifiedResources(usage, costs, identifyProject);
|
|
1576
|
+
|
|
1577
|
+
// If comparison data is available, apply it to calculate deltas
|
|
1578
|
+
if (priorUsage && priorCosts) {
|
|
1579
|
+
const priorResources = transformToUnifiedResources(priorUsage, priorCosts, identifyProject);
|
|
1580
|
+
resources = applyComparisonData(resources, priorResources);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// Render resources table
|
|
1584
|
+
renderUnifiedResourceTable(resources);
|
|
1585
|
+
|
|
1586
|
+
// Update workers breakdown table (Enhancement #1: per-project Workers breakdown)
|
|
1587
|
+
if (usage.workers && usage.workers.length > 0) {
|
|
1588
|
+
updateWorkersBreakdownTable(usage.workers);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Update resource count
|
|
1592
|
+
const countEl = document.getElementById('resource-count-value');
|
|
1593
|
+
if (countEl) {
|
|
1594
|
+
countEl.textContent = String(resources.length);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ========== Initialization ==========
|
|
1599
|
+
|
|
1600
|
+
export function initOverviewTab(): void {
|
|
1601
|
+
loadUsageData();
|
|
1602
|
+
initSortableHeaders();
|
|
1603
|
+
handleResponsiveSwitch();
|
|
1604
|
+
window.addEventListener('resize', handleResponsiveSwitch);
|
|
1605
|
+
|
|
1606
|
+
// Initialize daily chart data (nanostores)
|
|
1607
|
+
syncFromURL(); // Read initial state from URL params
|
|
1608
|
+
initSubscriptions(); // Set up auto-fetching when filters change
|
|
1609
|
+
fetchDailyData(); // Fetch initial daily data for chart
|
|
1610
|
+
|
|
1611
|
+
// Refresh button handler (bypasses cache)
|
|
1612
|
+
const refreshBtn = document.getElementById('refresh-data');
|
|
1613
|
+
if (refreshBtn) {
|
|
1614
|
+
refreshBtn.addEventListener('click', async () => {
|
|
1615
|
+
const icon = refreshBtn.querySelector('.refresh-icon');
|
|
1616
|
+
if (icon) {
|
|
1617
|
+
icon.classList.add('spinning');
|
|
1618
|
+
}
|
|
1619
|
+
refreshBtn.setAttribute('disabled', 'true');
|
|
1620
|
+
|
|
1621
|
+
try {
|
|
1622
|
+
await fetchDailyData({ nocache: true });
|
|
1623
|
+
// Also reload resource table data
|
|
1624
|
+
await loadUsageData(true);
|
|
1625
|
+
} finally {
|
|
1626
|
+
if (icon) {
|
|
1627
|
+
icon.classList.remove('spinning');
|
|
1628
|
+
}
|
|
1629
|
+
refreshBtn.removeAttribute('disabled');
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|