@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,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UnifiedShell Component
|
|
3
|
+
*
|
|
4
|
+
* Main orchestrator for the Unified Observability Dashboard.
|
|
5
|
+
* Handles data fetching, state management, auto-refresh, and URL sync.
|
|
6
|
+
*
|
|
7
|
+
* Industrial Command Centre aesthetic - data-dense, dark-first design.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
11
|
+
import { AlertTriangle } from 'lucide-react';
|
|
12
|
+
import { fetchWithDedup, clearFetchCache } from '../../../lib/usage/fetchWithDedup';
|
|
13
|
+
import { CF_ALLOWANCES, type ServiceType } from '../../../lib/usage/allowance-config';
|
|
14
|
+
import type {
|
|
15
|
+
Period,
|
|
16
|
+
Tab,
|
|
17
|
+
SortField,
|
|
18
|
+
SortDir,
|
|
19
|
+
BurnRateData,
|
|
20
|
+
ServiceUtilisation,
|
|
21
|
+
ProjectSummary,
|
|
22
|
+
ProjectTableRow,
|
|
23
|
+
ProjectBreakdown,
|
|
24
|
+
UsageRow,
|
|
25
|
+
StatusResponse,
|
|
26
|
+
QueryResponse,
|
|
27
|
+
UtilisationResponse,
|
|
28
|
+
ResourceMetric,
|
|
29
|
+
ResourceType,
|
|
30
|
+
DailyCostData,
|
|
31
|
+
DailyCostTotals,
|
|
32
|
+
OperationalStatus,
|
|
33
|
+
BillingContextResponse,
|
|
34
|
+
GranularResponse,
|
|
35
|
+
} from './types';
|
|
36
|
+
import { LiveHeader } from './LiveHeader';
|
|
37
|
+
import { HeroCardsRow } from './HeroCardsRow';
|
|
38
|
+
import { AlertBanner } from './AlertBanner';
|
|
39
|
+
import { Recommendations } from './Recommendations';
|
|
40
|
+
import { ProjectsTable } from './ProjectsTable';
|
|
41
|
+
import { UsageChart } from '../react/UsageChart';
|
|
42
|
+
import { FeatureBudgets } from './FeatureBudgets';
|
|
43
|
+
import { CircuitBreakerEvents } from './CircuitBreakerEvents';
|
|
44
|
+
|
|
45
|
+
interface UnifiedShellProps {
|
|
46
|
+
initialPeriod?: Period;
|
|
47
|
+
initialTab?: Tab;
|
|
48
|
+
initialSearch?: string;
|
|
49
|
+
initialSort?: SortField;
|
|
50
|
+
initialDir?: SortDir;
|
|
51
|
+
initialExpanded?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const REFRESH_INTERVAL = 60_000; // 60 seconds
|
|
55
|
+
const CACHE_TTL = 60_000; // 60 seconds for resource breakdown cache
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Error state component
|
|
59
|
+
*/
|
|
60
|
+
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="bg-rose-500/10 border border-rose-500/30 rounded-sm p-6 flex items-start gap-4">
|
|
63
|
+
<AlertTriangle className="w-5 h-5 text-rose-400 flex-shrink-0 mt-0.5" />
|
|
64
|
+
<div className="flex-1">
|
|
65
|
+
<h3 className="text-rose-800 dark:text-rose-200 font-semibold text-sm">
|
|
66
|
+
Failed to load data
|
|
67
|
+
</h3>
|
|
68
|
+
<p className="text-rose-700/80 dark:text-rose-300/80 text-xs mt-1 font-mono">{message}</p>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={onRetry}
|
|
72
|
+
className="mt-3 px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-800 dark:text-rose-200 text-xs font-mono rounded-sm transition-colors"
|
|
73
|
+
>
|
|
74
|
+
Retry
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Loading skeleton
|
|
83
|
+
*/
|
|
84
|
+
function LoadingSkeleton() {
|
|
85
|
+
return (
|
|
86
|
+
<div className="space-y-6 animate-pulse">
|
|
87
|
+
{/* Header skeleton */}
|
|
88
|
+
<div className="flex justify-between items-center">
|
|
89
|
+
<div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-48" />
|
|
90
|
+
<div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32" />
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Chart skeleton */}
|
|
94
|
+
<div className="space-y-3">
|
|
95
|
+
<div className="h-5 bg-gray-100 dark:bg-slate-800 rounded w-36" />
|
|
96
|
+
<div className="h-48 bg-gray-50 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Hero cards skeleton */}
|
|
100
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
101
|
+
<div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
|
|
102
|
+
<div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
|
|
103
|
+
<div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Table skeleton */}
|
|
107
|
+
<div className="space-y-3">
|
|
108
|
+
<div className="h-5 bg-gray-100 dark:bg-slate-800 rounded w-24" />
|
|
109
|
+
<div className="h-64 bg-gray-50 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function UnifiedShell({
|
|
116
|
+
initialPeriod = '24h',
|
|
117
|
+
initialTab = 'overview',
|
|
118
|
+
initialSearch = '',
|
|
119
|
+
initialSort = 'cost',
|
|
120
|
+
initialDir = 'desc',
|
|
121
|
+
initialExpanded = [],
|
|
122
|
+
}: UnifiedShellProps) {
|
|
123
|
+
// Core state
|
|
124
|
+
const [period, setPeriod] = useState<Period>(initialPeriod);
|
|
125
|
+
const [tab, setTab] = useState<Tab>(initialTab);
|
|
126
|
+
const [search, setSearch] = useState(initialSearch);
|
|
127
|
+
const [sort, setSort] = useState<SortField>(initialSort);
|
|
128
|
+
const [sortDir, setSortDir] = useState<SortDir>(initialDir);
|
|
129
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set(initialExpanded));
|
|
130
|
+
|
|
131
|
+
// Loading & error state
|
|
132
|
+
const [loading, setLoading] = useState(true);
|
|
133
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
134
|
+
const [error, setError] = useState<string | null>(null);
|
|
135
|
+
|
|
136
|
+
// Data state
|
|
137
|
+
const [burnRate, setBurnRate] = useState<BurnRateData | null>(null);
|
|
138
|
+
const [services, setServices] = useState<ServiceUtilisation[]>([]);
|
|
139
|
+
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
|
140
|
+
const [usageData, setUsageData] = useState<UsageRow[]>([]);
|
|
141
|
+
const [statusMap, setStatusMap] = useState<
|
|
142
|
+
Record<string, { status: string; circuitBreaker: string }>
|
|
143
|
+
>({});
|
|
144
|
+
|
|
145
|
+
// Billing context state (for Plan Health cards)
|
|
146
|
+
const [billingContext, setBillingContext] = useState<BillingContextResponse | null>(null);
|
|
147
|
+
|
|
148
|
+
// Granular usage data (for stacked bar chart)
|
|
149
|
+
const [granularData, setGranularData] = useState<GranularResponse | null>(null);
|
|
150
|
+
|
|
151
|
+
// Lazy-loaded resource breakdown cache
|
|
152
|
+
const [resourceCache, setResourceCache] = useState<Map<string, ProjectBreakdown>>(new Map());
|
|
153
|
+
// Use ref to avoid dependency cycle in fetchResourceBreakdown
|
|
154
|
+
const resourceCacheRef = useRef(resourceCache);
|
|
155
|
+
resourceCacheRef.current = resourceCache;
|
|
156
|
+
|
|
157
|
+
// Dismissed alerts (persisted to localStorage)
|
|
158
|
+
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(() => {
|
|
159
|
+
if (typeof window === 'undefined') return new Set();
|
|
160
|
+
try {
|
|
161
|
+
const stored = localStorage.getItem('unified-dismissed-alerts');
|
|
162
|
+
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
163
|
+
} catch {
|
|
164
|
+
return new Set();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sync state to URL
|
|
170
|
+
*/
|
|
171
|
+
const syncToURL = useCallback(() => {
|
|
172
|
+
const url = new URL(window.location.href);
|
|
173
|
+
url.searchParams.set('period', period);
|
|
174
|
+
url.searchParams.set('tab', tab);
|
|
175
|
+
|
|
176
|
+
if (search) {
|
|
177
|
+
url.searchParams.set('search', search);
|
|
178
|
+
} else {
|
|
179
|
+
url.searchParams.delete('search');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
url.searchParams.set('sort', sort);
|
|
183
|
+
url.searchParams.set('dir', sortDir);
|
|
184
|
+
|
|
185
|
+
if (expanded.size > 0) {
|
|
186
|
+
url.searchParams.set('expanded', Array.from(expanded).join(','));
|
|
187
|
+
} else {
|
|
188
|
+
url.searchParams.delete('expanded');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
history.replaceState(null, '', url.toString());
|
|
192
|
+
}, [period, tab, search, sort, sortDir, expanded]);
|
|
193
|
+
|
|
194
|
+
// Sync URL on state change
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
syncToURL();
|
|
197
|
+
}, [syncToURL]);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Fetch utilisation data (hero cards + projects)
|
|
201
|
+
*/
|
|
202
|
+
const fetchUtilisation = useCallback(async () => {
|
|
203
|
+
const data = await fetchWithDedup<UtilisationResponse>('/api/usage/utilization');
|
|
204
|
+
if (!data.success) throw new Error('Utilisation API returned unsuccessful response');
|
|
205
|
+
|
|
206
|
+
setBurnRate(data.burnRate);
|
|
207
|
+
setServices(data.cloudflareServices);
|
|
208
|
+
setProjects(data.projects);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Fetch status data (circuit breakers, operational status)
|
|
213
|
+
*/
|
|
214
|
+
const fetchStatus = useCallback(async () => {
|
|
215
|
+
const data = await fetchWithDedup<StatusResponse>(`/api/usage/status?period=${period}`);
|
|
216
|
+
if (!data.success) throw new Error('Status API returned unsuccessful response');
|
|
217
|
+
|
|
218
|
+
setStatusMap(data.projects as Record<string, { status: string; circuitBreaker: string }>);
|
|
219
|
+
}, [period]);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fetch usage query data (chart)
|
|
223
|
+
* Uses hour grouping for 24h period, day grouping for longer periods
|
|
224
|
+
*/
|
|
225
|
+
const fetchQuery = useCallback(async () => {
|
|
226
|
+
// Use hourly granularity for 24h, daily for longer periods
|
|
227
|
+
const groupBy = period === '24h' ? 'hour' : 'day';
|
|
228
|
+
const data = await fetchWithDedup<QueryResponse>(
|
|
229
|
+
`/api/usage/query?period=${period}&groupBy=${groupBy}`
|
|
230
|
+
);
|
|
231
|
+
if (!data.success) throw new Error('Query API returned unsuccessful response');
|
|
232
|
+
|
|
233
|
+
setUsageData(data.data);
|
|
234
|
+
}, [period]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Fetch billing context (for Plan Health cards)
|
|
238
|
+
*/
|
|
239
|
+
const fetchBillingContext = useCallback(async () => {
|
|
240
|
+
const data = await fetchWithDedup<BillingContextResponse>(
|
|
241
|
+
`/api/usage/billing-context?period=${period}`
|
|
242
|
+
);
|
|
243
|
+
if (!data.success) throw new Error('Billing context API returned unsuccessful response');
|
|
244
|
+
|
|
245
|
+
setBillingContext(data);
|
|
246
|
+
}, [period]);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fetch granular usage data (for stacked bar chart)
|
|
250
|
+
*/
|
|
251
|
+
const fetchGranular = useCallback(async () => {
|
|
252
|
+
const data = await fetchWithDedup<GranularResponse>(`/api/usage/granular?period=${period}`);
|
|
253
|
+
if (!data.success) throw new Error('Granular API returned unsuccessful response');
|
|
254
|
+
|
|
255
|
+
setGranularData(data);
|
|
256
|
+
}, [period]);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Main data fetch
|
|
260
|
+
*/
|
|
261
|
+
const fetchData = useCallback(
|
|
262
|
+
async (isBackground = false) => {
|
|
263
|
+
if (isBackground) {
|
|
264
|
+
setIsRefreshing(true);
|
|
265
|
+
} else {
|
|
266
|
+
setLoading(true);
|
|
267
|
+
setError(null);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await Promise.all([
|
|
272
|
+
fetchUtilisation(),
|
|
273
|
+
fetchStatus(),
|
|
274
|
+
fetchQuery(),
|
|
275
|
+
fetchBillingContext(),
|
|
276
|
+
fetchGranular(),
|
|
277
|
+
]);
|
|
278
|
+
setError(null);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (!isBackground) {
|
|
281
|
+
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
|
282
|
+
}
|
|
283
|
+
console.error('[UnifiedShell] Fetch error:', err);
|
|
284
|
+
} finally {
|
|
285
|
+
setLoading(false);
|
|
286
|
+
setIsRefreshing(false);
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
[fetchUtilisation, fetchStatus, fetchQuery, fetchBillingContext, fetchGranular]
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Fetch resource breakdown for a project (lazy loading)
|
|
294
|
+
* Uses ref for cache check to avoid dependency cycle
|
|
295
|
+
*/
|
|
296
|
+
const fetchResourceBreakdown = useCallback(
|
|
297
|
+
async (projectId: string): Promise<ProjectBreakdown | null> => {
|
|
298
|
+
// Check cache first (use ref to avoid dependency cycle)
|
|
299
|
+
const cached = resourceCacheRef.current.get(projectId);
|
|
300
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
|
|
301
|
+
return cached;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const response = await fetch(`/api/usage/daily?period=${period}&project=${projectId}`, {
|
|
306
|
+
credentials: 'include',
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
console.error(`Failed to fetch breakdown for ${projectId}: ${response.status}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const data = await response.json();
|
|
315
|
+
if (!data.success) return null;
|
|
316
|
+
|
|
317
|
+
// Transform the API response (object with totals) into ResourceMetric[] format
|
|
318
|
+
const dailyData = data.data as DailyCostData;
|
|
319
|
+
const resources: ResourceMetric[] = [];
|
|
320
|
+
const totals: DailyCostTotals = dailyData?.totals || {
|
|
321
|
+
workers: 0,
|
|
322
|
+
d1: 0,
|
|
323
|
+
kv: 0,
|
|
324
|
+
r2: 0,
|
|
325
|
+
durableObjects: 0,
|
|
326
|
+
vectorize: 0,
|
|
327
|
+
queues: 0,
|
|
328
|
+
total: 0,
|
|
329
|
+
};
|
|
330
|
+
const days = dailyData?.days || [];
|
|
331
|
+
|
|
332
|
+
// Build sparkline trends from daily data (last 6 points)
|
|
333
|
+
const buildTrend = (field: keyof DailyCostTotals): number[] => {
|
|
334
|
+
if (days.length === 0) return [];
|
|
335
|
+
const recentDays = days.slice(-6);
|
|
336
|
+
return recentDays.map((d) => d[field] || 0);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Cost-to-usage conversion factors (reverse-engineered from Cloudflare pricing)
|
|
340
|
+
// These convert dollar costs back to approximate usage units
|
|
341
|
+
const costToUsage = (cost: number, type: ResourceType): number => {
|
|
342
|
+
switch (type) {
|
|
343
|
+
case 'd1':
|
|
344
|
+
// D1: $0.75 per 1M rows written → cost * 1M / 0.75 ≈ cost * 1.33M
|
|
345
|
+
return cost * 1_333_333;
|
|
346
|
+
case 'kv':
|
|
347
|
+
// KV: $5 per 1M writes → cost * 1M / 5 = cost * 200K
|
|
348
|
+
return (cost / 5) * 1_000_000;
|
|
349
|
+
case 'workers':
|
|
350
|
+
// Workers: $0.30 per 1M requests → cost * 1M / 0.30 ≈ cost * 3.33M
|
|
351
|
+
return (cost / 0.3) * 1_000_000;
|
|
352
|
+
case 'vectorize':
|
|
353
|
+
// Vectorize: $0.01 per 1M dimensions queried → cost * 1M / 0.01 = cost * 100M
|
|
354
|
+
return (cost / 0.01) * 1_000_000;
|
|
355
|
+
case 'r2':
|
|
356
|
+
// R2: $4.50 per 1M Class A ops → cost * 1M / 4.5 ≈ cost * 222K
|
|
357
|
+
return (cost / 4.5) * 1_000_000;
|
|
358
|
+
case 'durableObjects':
|
|
359
|
+
// DO: $0.15 per 1M requests → cost * 1M / 0.15 ≈ cost * 6.67M
|
|
360
|
+
return (cost / 0.15) * 1_000_000;
|
|
361
|
+
case 'queues':
|
|
362
|
+
// Queues: $0.40 per 1M messages → cost * 1M / 0.4 = cost * 2.5M
|
|
363
|
+
return (cost / 0.4) * 1_000_000;
|
|
364
|
+
default:
|
|
365
|
+
return cost;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Map each service with cost > 0 to a ResourceMetric
|
|
370
|
+
const serviceMap: Array<{
|
|
371
|
+
key: keyof DailyCostTotals;
|
|
372
|
+
type: ResourceType;
|
|
373
|
+
label: string;
|
|
374
|
+
unit: string;
|
|
375
|
+
}> = [
|
|
376
|
+
{ key: 'd1', type: 'd1', label: 'D1 Database', unit: 'rows written' },
|
|
377
|
+
{ key: 'kv', type: 'kv', label: 'KV Storage', unit: 'writes' },
|
|
378
|
+
{ key: 'r2', type: 'r2', label: 'R2 Storage', unit: 'Class A ops' },
|
|
379
|
+
{ key: 'workers', type: 'workers', label: 'Workers', unit: 'requests' },
|
|
380
|
+
{
|
|
381
|
+
key: 'durableObjects',
|
|
382
|
+
type: 'durableObjects',
|
|
383
|
+
label: 'Durable Objects',
|
|
384
|
+
unit: 'requests',
|
|
385
|
+
},
|
|
386
|
+
{ key: 'vectorize', type: 'vectorize', label: 'Vectorize', unit: 'dimensions' },
|
|
387
|
+
{ key: 'queues', type: 'queues', label: 'Queues', unit: 'messages' },
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
for (const svc of serviceMap) {
|
|
391
|
+
const cost = totals[svc.key] || 0;
|
|
392
|
+
if (cost > 0 || days.some((d) => (d[svc.key] || 0) > 0)) {
|
|
393
|
+
const usage = costToUsage(cost, svc.type);
|
|
394
|
+
const allowance = CF_ALLOWANCES[svc.type as ServiceType];
|
|
395
|
+
const limit = allowance?.monthlyLimit ?? Infinity;
|
|
396
|
+
const limitPct = limit !== Infinity && limit > 0 ? (usage / limit) * 100 : undefined;
|
|
397
|
+
|
|
398
|
+
// Format usage with appropriate units
|
|
399
|
+
let usageFormatted: string;
|
|
400
|
+
if (usage >= 1_000_000) {
|
|
401
|
+
usageFormatted = `${(usage / 1_000_000).toFixed(1)}M ${svc.unit}`;
|
|
402
|
+
} else if (usage >= 1_000) {
|
|
403
|
+
usageFormatted = `${(usage / 1_000).toFixed(1)}K ${svc.unit}`;
|
|
404
|
+
} else {
|
|
405
|
+
usageFormatted = `${Math.round(usage)} ${svc.unit}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
resources.push({
|
|
409
|
+
type: svc.type,
|
|
410
|
+
label: svc.label,
|
|
411
|
+
cost,
|
|
412
|
+
usage,
|
|
413
|
+
usageFormatted,
|
|
414
|
+
unit: svc.unit,
|
|
415
|
+
limit,
|
|
416
|
+
limitPct,
|
|
417
|
+
trend: buildTrend(svc.key),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const breakdown: ProjectBreakdown = {
|
|
423
|
+
projectId,
|
|
424
|
+
resources,
|
|
425
|
+
totalCost: totals.total || 0,
|
|
426
|
+
fetchedAt: Date.now(),
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
setResourceCache((prev) => new Map(prev).set(projectId, breakdown));
|
|
430
|
+
return breakdown;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
console.error(`Error fetching breakdown for ${projectId}:`, err);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
[period]
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// Initial fetch and refetch on period change
|
|
440
|
+
// Note: fetchData already depends on period via fetchStatus/fetchQuery
|
|
441
|
+
// Intentionally only depending on `period` to avoid refetch loops
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
fetchData();
|
|
444
|
+
}, [period]);
|
|
445
|
+
|
|
446
|
+
// Auto-refresh interval
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
const interval = setInterval(() => {
|
|
449
|
+
fetchData(true);
|
|
450
|
+
}, REFRESH_INTERVAL);
|
|
451
|
+
|
|
452
|
+
return () => clearInterval(interval);
|
|
453
|
+
}, [fetchData]);
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Handle period change
|
|
457
|
+
*/
|
|
458
|
+
const handlePeriodChange = useCallback((newPeriod: Period) => {
|
|
459
|
+
setPeriod(newPeriod);
|
|
460
|
+
// Clear resource cache on period change
|
|
461
|
+
setResourceCache(new Map());
|
|
462
|
+
// Clear fetch dedup cache to ensure fresh data for new period
|
|
463
|
+
clearFetchCache('/api/usage/');
|
|
464
|
+
}, []);
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Handle search change (debounced in LiveHeader)
|
|
468
|
+
*/
|
|
469
|
+
const handleSearchChange = useCallback((value: string) => {
|
|
470
|
+
setSearch(value);
|
|
471
|
+
}, []);
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Handle sort change
|
|
475
|
+
*/
|
|
476
|
+
const handleSortChange = useCallback(
|
|
477
|
+
(field: SortField) => {
|
|
478
|
+
if (field === sort) {
|
|
479
|
+
// Toggle direction
|
|
480
|
+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
481
|
+
} else {
|
|
482
|
+
setSort(field);
|
|
483
|
+
setSortDir('desc');
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
[sort]
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Handle row expand/collapse
|
|
491
|
+
* Side effect (fetchResourceBreakdown) is called outside setState to avoid anti-pattern
|
|
492
|
+
*/
|
|
493
|
+
const handleExpand = useCallback(
|
|
494
|
+
(projectId: string) => {
|
|
495
|
+
setExpanded((prev) => {
|
|
496
|
+
const next = new Set(prev);
|
|
497
|
+
const wasExpanded = next.has(projectId);
|
|
498
|
+
|
|
499
|
+
if (wasExpanded) {
|
|
500
|
+
next.delete(projectId);
|
|
501
|
+
} else {
|
|
502
|
+
next.add(projectId);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Schedule lazy load outside of setState (after render)
|
|
506
|
+
if (!wasExpanded) {
|
|
507
|
+
// Use queueMicrotask to ensure state update completes first
|
|
508
|
+
queueMicrotask(() => {
|
|
509
|
+
fetchResourceBreakdown(projectId);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return next;
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
[fetchResourceBreakdown]
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Handle tab change
|
|
521
|
+
*/
|
|
522
|
+
const handleTabChange = useCallback((newTab: Tab) => {
|
|
523
|
+
setTab(newTab);
|
|
524
|
+
}, []);
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Handle alert dismissal
|
|
528
|
+
*/
|
|
529
|
+
const handleDismissAlert = useCallback((alertId: string) => {
|
|
530
|
+
setDismissedAlerts((prev) => {
|
|
531
|
+
const next = new Set(prev);
|
|
532
|
+
next.add(alertId);
|
|
533
|
+
// Persist to localStorage
|
|
534
|
+
try {
|
|
535
|
+
localStorage.setItem('unified-dismissed-alerts', JSON.stringify([...next]));
|
|
536
|
+
} catch {
|
|
537
|
+
// Ignore localStorage errors
|
|
538
|
+
}
|
|
539
|
+
return next;
|
|
540
|
+
});
|
|
541
|
+
}, []);
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Hero Card Interactivity Handlers
|
|
545
|
+
*/
|
|
546
|
+
|
|
547
|
+
// MTD Spend Card: Scroll to recommendations
|
|
548
|
+
const handleMTDSpendClick = useCallback(() => {
|
|
549
|
+
const el = document.getElementById('recommendations-section');
|
|
550
|
+
if (el) {
|
|
551
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
552
|
+
// Add highlight effect
|
|
553
|
+
el.classList.add('ring-2', 'ring-blue-500/50');
|
|
554
|
+
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-500/50'), 2000);
|
|
555
|
+
}
|
|
556
|
+
}, []);
|
|
557
|
+
|
|
558
|
+
// Plan Utilisation Card: Show service in table, could filter or highlight
|
|
559
|
+
const handlePlanUtilisationClick = useCallback((_serviceId: string) => {
|
|
560
|
+
// Scroll to projects table
|
|
561
|
+
const el = document.getElementById('projects-section');
|
|
562
|
+
if (el) {
|
|
563
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
564
|
+
}
|
|
565
|
+
}, []);
|
|
566
|
+
|
|
567
|
+
// Top Spender Card: Sort table by cost
|
|
568
|
+
const handleTopSpenderClick = useCallback(() => {
|
|
569
|
+
setSort('cost');
|
|
570
|
+
setSortDir('desc');
|
|
571
|
+
// Scroll to projects table
|
|
572
|
+
const el = document.getElementById('projects-section');
|
|
573
|
+
if (el) {
|
|
574
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
575
|
+
}
|
|
576
|
+
}, []);
|
|
577
|
+
|
|
578
|
+
// System Health Card: Filter to critical/warning projects
|
|
579
|
+
const [statusFilter, setStatusFilter] = useState<'all' | 'critical' | 'warning'>('all');
|
|
580
|
+
|
|
581
|
+
const handleSystemHealthClick = useCallback((filter: 'critical' | 'warning' | 'all') => {
|
|
582
|
+
setStatusFilter(filter);
|
|
583
|
+
// Scroll to projects table
|
|
584
|
+
const el = document.getElementById('projects-section');
|
|
585
|
+
if (el) {
|
|
586
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
587
|
+
}
|
|
588
|
+
}, []);
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Handle export
|
|
592
|
+
*/
|
|
593
|
+
const handleExport = useCallback(() => {
|
|
594
|
+
if (!projects || projects.length === 0) {
|
|
595
|
+
console.warn('[Export] No projects data available');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Build CSV from current data
|
|
600
|
+
const rows: string[] = ['Project,MTD Cost,Delta %,Status'];
|
|
601
|
+
for (const proj of projects) {
|
|
602
|
+
// Escape any commas in project names
|
|
603
|
+
const name = proj.projectName.includes(',') ? `"${proj.projectName}"` : proj.projectName;
|
|
604
|
+
rows.push(
|
|
605
|
+
`${name},${proj.mtdCost.toFixed(2)},${proj.costDeltaPct.toFixed(1)},${proj.status}`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const csv = rows.join('\n');
|
|
610
|
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
611
|
+
const url = URL.createObjectURL(blob);
|
|
612
|
+
|
|
613
|
+
const link = document.createElement('a');
|
|
614
|
+
link.href = url;
|
|
615
|
+
link.download = `usage-${period}-${new Date().toISOString().split('T')[0]}.csv`;
|
|
616
|
+
link.style.display = 'none';
|
|
617
|
+
document.body.appendChild(link);
|
|
618
|
+
link.click();
|
|
619
|
+
document.body.removeChild(link);
|
|
620
|
+
|
|
621
|
+
// Cleanup
|
|
622
|
+
setTimeout(() => URL.revokeObjectURL(url), 100);
|
|
623
|
+
}, [projects, period]);
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Derive operational status based on metrics
|
|
627
|
+
*/
|
|
628
|
+
const deriveStatus = useCallback(
|
|
629
|
+
(
|
|
630
|
+
project: ProjectSummary,
|
|
631
|
+
circuitBreakerState: 'active' | 'tripped' | 'degraded' | undefined
|
|
632
|
+
): OperationalStatus => {
|
|
633
|
+
const utilizationPct = project.utilizationPct;
|
|
634
|
+
|
|
635
|
+
// CRITICAL/STOP: Over 100% of plan limits
|
|
636
|
+
if (utilizationPct > 100) return 'STOP';
|
|
637
|
+
|
|
638
|
+
// WARN: Approaching limits (>80%) or circuit breaker tripped/degraded
|
|
639
|
+
if (
|
|
640
|
+
utilizationPct > 80 ||
|
|
641
|
+
circuitBreakerState === 'tripped' ||
|
|
642
|
+
circuitBreakerState === 'degraded'
|
|
643
|
+
) {
|
|
644
|
+
return 'WARN';
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// RUN: Operating normally
|
|
648
|
+
return 'RUN';
|
|
649
|
+
},
|
|
650
|
+
[]
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Calculate delta from sparkline data
|
|
655
|
+
*/
|
|
656
|
+
const calculateDeltaFromSparkline = useCallback((sparkline: number[]): number => {
|
|
657
|
+
if (!sparkline || sparkline.length < 2) return 0;
|
|
658
|
+
const recent = sparkline[sparkline.length - 1];
|
|
659
|
+
const previous = sparkline[sparkline.length - 2];
|
|
660
|
+
if (previous === 0) return recent > 0 ? 100 : 0;
|
|
661
|
+
return ((recent - previous) / previous) * 100;
|
|
662
|
+
}, []);
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Transform projects to table rows
|
|
666
|
+
*/
|
|
667
|
+
const tableRows: ProjectTableRow[] = useMemo(() => {
|
|
668
|
+
let filtered = projects.map((p) => {
|
|
669
|
+
const cbStatus =
|
|
670
|
+
p.circuitBreakerStatus ||
|
|
671
|
+
(statusMap[p.projectId]?.circuitBreaker as 'active' | 'tripped' | 'degraded') ||
|
|
672
|
+
'active';
|
|
673
|
+
|
|
674
|
+
// Derive status from utilization metrics, not just from statusMap
|
|
675
|
+
const derivedStatus = deriveStatus(p, cbStatus === 'active' ? undefined : cbStatus);
|
|
676
|
+
|
|
677
|
+
// Calculate delta from sparkline if costDeltaPct is 0
|
|
678
|
+
const delta =
|
|
679
|
+
p.costDeltaPct !== 0 ? p.costDeltaPct : calculateDeltaFromSparkline(p.sparklineData || []);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
id: p.projectId,
|
|
683
|
+
name: p.projectName,
|
|
684
|
+
status: derivedStatus,
|
|
685
|
+
mtdCost: p.mtdCost,
|
|
686
|
+
costDeltaPct: delta,
|
|
687
|
+
activity: p.utilizationCurrent,
|
|
688
|
+
activityTrend: p.sparklineData || [],
|
|
689
|
+
circuitBreaker: cbStatus === 'degraded' ? 'tripped' : (cbStatus as 'active' | 'tripped'),
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Apply search filter
|
|
694
|
+
if (search) {
|
|
695
|
+
const term = search.toLowerCase();
|
|
696
|
+
filtered = filtered.filter((r) => r.name.toLowerCase().includes(term));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Apply status filter from card clicks
|
|
700
|
+
if (statusFilter === 'critical') {
|
|
701
|
+
filtered = filtered.filter((r) => r.status === 'STOP');
|
|
702
|
+
} else if (statusFilter === 'warning') {
|
|
703
|
+
filtered = filtered.filter((r) => r.status === 'WARN' || r.status === 'STOP');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Apply sort
|
|
707
|
+
filtered.sort((a, b) => {
|
|
708
|
+
let cmp = 0;
|
|
709
|
+
if (sort === 'name') {
|
|
710
|
+
cmp = a.name.localeCompare(b.name);
|
|
711
|
+
} else if (sort === 'cost') {
|
|
712
|
+
cmp = a.mtdCost - b.mtdCost;
|
|
713
|
+
} else if (sort === 'activity') {
|
|
714
|
+
cmp = a.activity - b.activity;
|
|
715
|
+
}
|
|
716
|
+
return sortDir === 'desc' ? -cmp : cmp;
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
return filtered;
|
|
720
|
+
}, [
|
|
721
|
+
projects,
|
|
722
|
+
statusMap,
|
|
723
|
+
search,
|
|
724
|
+
sort,
|
|
725
|
+
sortDir,
|
|
726
|
+
statusFilter,
|
|
727
|
+
deriveStatus,
|
|
728
|
+
calculateDeltaFromSparkline,
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
// Render loading state
|
|
732
|
+
if (loading) {
|
|
733
|
+
return (
|
|
734
|
+
<div className="p-6 bg-white dark:bg-slate-950 min-h-screen">
|
|
735
|
+
<LoadingSkeleton />
|
|
736
|
+
</div>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Render error state
|
|
741
|
+
if (error) {
|
|
742
|
+
return (
|
|
743
|
+
<div className="p-6 bg-white dark:bg-slate-950 min-h-screen">
|
|
744
|
+
<ErrorState message={error} onRetry={() => fetchData()} />
|
|
745
|
+
</div>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return (
|
|
750
|
+
<div className="p-6 bg-white dark:bg-slate-950 min-h-screen space-y-6">
|
|
751
|
+
{/* Header with controls */}
|
|
752
|
+
<LiveHeader
|
|
753
|
+
period={period}
|
|
754
|
+
onPeriodChange={handlePeriodChange}
|
|
755
|
+
search={search}
|
|
756
|
+
onSearchChange={handleSearchChange}
|
|
757
|
+
isRefreshing={isRefreshing}
|
|
758
|
+
onExport={handleExport}
|
|
759
|
+
/>
|
|
760
|
+
|
|
761
|
+
{/* Tab Navigation */}
|
|
762
|
+
<div className="flex gap-1 p-1 bg-gray-100/50 dark:bg-slate-800/50 rounded-sm w-fit">
|
|
763
|
+
<button
|
|
764
|
+
type="button"
|
|
765
|
+
onClick={() => handleTabChange('overview')}
|
|
766
|
+
className={`px-4 py-1.5 text-xs font-mono uppercase tracking-wider rounded-sm transition-all ${
|
|
767
|
+
tab === 'overview'
|
|
768
|
+
? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100'
|
|
769
|
+
: 'text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:bg-gray-200/50 dark:hover:bg-slate-700/50'
|
|
770
|
+
}`}
|
|
771
|
+
>
|
|
772
|
+
Overview
|
|
773
|
+
</button>
|
|
774
|
+
<button
|
|
775
|
+
type="button"
|
|
776
|
+
onClick={() => handleTabChange('features')}
|
|
777
|
+
className={`px-4 py-1.5 text-xs font-mono uppercase tracking-wider rounded-sm transition-all ${
|
|
778
|
+
tab === 'features'
|
|
779
|
+
? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100'
|
|
780
|
+
: 'text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:bg-gray-200/50 dark:hover:bg-slate-700/50'
|
|
781
|
+
}`}
|
|
782
|
+
>
|
|
783
|
+
Features
|
|
784
|
+
</button>
|
|
785
|
+
</div>
|
|
786
|
+
|
|
787
|
+
{/* Alert Banner - shows critical alerts */}
|
|
788
|
+
<AlertBanner
|
|
789
|
+
services={services}
|
|
790
|
+
burnRate={burnRate}
|
|
791
|
+
onDismiss={handleDismissAlert}
|
|
792
|
+
dismissedAlerts={dismissedAlerts}
|
|
793
|
+
/>
|
|
794
|
+
|
|
795
|
+
{tab === 'overview' && (
|
|
796
|
+
<>
|
|
797
|
+
{/* Activity Chart */}
|
|
798
|
+
<section>
|
|
799
|
+
<h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider mb-3">
|
|
800
|
+
Activity Timeline
|
|
801
|
+
</h2>
|
|
802
|
+
<UsageChart
|
|
803
|
+
data={usageData}
|
|
804
|
+
loading={isRefreshing && usageData.length === 0}
|
|
805
|
+
period={period}
|
|
806
|
+
granularData={granularData}
|
|
807
|
+
chartType="stacked"
|
|
808
|
+
/>
|
|
809
|
+
</section>
|
|
810
|
+
|
|
811
|
+
{/* Hero Cards */}
|
|
812
|
+
<HeroCardsRow
|
|
813
|
+
burnRate={burnRate}
|
|
814
|
+
services={services}
|
|
815
|
+
projects={tableRows}
|
|
816
|
+
billingContext={billingContext}
|
|
817
|
+
usageTotals={
|
|
818
|
+
granularData?.totals?.byTool
|
|
819
|
+
? (Object.fromEntries(
|
|
820
|
+
Object.entries(granularData.totals.byTool).map(([k, v]) => [k, v.requests])
|
|
821
|
+
) as Record<
|
|
822
|
+
| 'workers'
|
|
823
|
+
| 'd1'
|
|
824
|
+
| 'kv'
|
|
825
|
+
| 'r2'
|
|
826
|
+
| 'vectorize'
|
|
827
|
+
| 'durableObjects'
|
|
828
|
+
| 'queues'
|
|
829
|
+
| 'workersAI'
|
|
830
|
+
| 'pages',
|
|
831
|
+
number
|
|
832
|
+
>)
|
|
833
|
+
: undefined
|
|
834
|
+
}
|
|
835
|
+
onMTDSpendClick={handleMTDSpendClick}
|
|
836
|
+
onPlanUtilisationClick={handlePlanUtilisationClick}
|
|
837
|
+
onTopSpenderClick={handleTopSpenderClick}
|
|
838
|
+
onSystemHealthClick={handleSystemHealthClick}
|
|
839
|
+
/>
|
|
840
|
+
|
|
841
|
+
{/* Projects Table */}
|
|
842
|
+
<section id="projects-section">
|
|
843
|
+
<div className="flex items-center justify-between mb-3">
|
|
844
|
+
<h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
|
845
|
+
Projects
|
|
846
|
+
</h2>
|
|
847
|
+
{statusFilter !== 'all' && (
|
|
848
|
+
<button
|
|
849
|
+
type="button"
|
|
850
|
+
onClick={() => setStatusFilter('all')}
|
|
851
|
+
className="text-xs text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
|
852
|
+
>
|
|
853
|
+
Clear filter ({statusFilter})
|
|
854
|
+
</button>
|
|
855
|
+
)}
|
|
856
|
+
</div>
|
|
857
|
+
<ProjectsTable
|
|
858
|
+
rows={tableRows}
|
|
859
|
+
expanded={expanded}
|
|
860
|
+
onExpand={handleExpand}
|
|
861
|
+
onSort={handleSortChange}
|
|
862
|
+
sort={sort}
|
|
863
|
+
sortDir={sortDir}
|
|
864
|
+
resourceCache={resourceCache}
|
|
865
|
+
fetchResourceBreakdown={fetchResourceBreakdown}
|
|
866
|
+
/>
|
|
867
|
+
</section>
|
|
868
|
+
|
|
869
|
+
{/* Recommendations */}
|
|
870
|
+
<section id="recommendations-section" className="transition-all duration-300 rounded-sm">
|
|
871
|
+
<Recommendations services={services} projects={projects} burnRate={burnRate} />
|
|
872
|
+
</section>
|
|
873
|
+
</>
|
|
874
|
+
)}
|
|
875
|
+
|
|
876
|
+
{tab === 'features' && (
|
|
877
|
+
<div className="space-y-6">
|
|
878
|
+
{/* Feature Budgets Table */}
|
|
879
|
+
<section>
|
|
880
|
+
<FeatureBudgets />
|
|
881
|
+
</section>
|
|
882
|
+
|
|
883
|
+
{/* Circuit Breaker Events Log */}
|
|
884
|
+
<section>
|
|
885
|
+
<CircuitBreakerEvents />
|
|
886
|
+
</section>
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export default UnifiedShell;
|