@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,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Third-Party Provider Stubs
|
|
3
|
+
*
|
|
4
|
+
* Interface definitions and fetcher stubs for third-party service usage tracking.
|
|
5
|
+
* These will be implemented incrementally as integrations are built.
|
|
6
|
+
*
|
|
7
|
+
* Supported providers (planned):
|
|
8
|
+
* - GitHub (Actions minutes, Advanced Security seats, storage)
|
|
9
|
+
* - OpenAI (API usage, tokens)
|
|
10
|
+
* - Apify (Actor runs, compute units)
|
|
11
|
+
* - Anthropic (API usage, tokens)
|
|
12
|
+
* - Resend (emails sent)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provider identifier
|
|
17
|
+
*/
|
|
18
|
+
export type ProviderId = 'github' | 'openai' | 'apify' | 'anthropic' | 'resend';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Usage record from a third-party provider
|
|
22
|
+
*/
|
|
23
|
+
export interface ThirdPartyUsageRecord {
|
|
24
|
+
/** Provider identifier */
|
|
25
|
+
provider: ProviderId;
|
|
26
|
+
/** Type of resource (e.g., 'actions_minutes', 'api_tokens') */
|
|
27
|
+
resourceType: string;
|
|
28
|
+
/** Optional specific resource name */
|
|
29
|
+
resourceName?: string;
|
|
30
|
+
/** Numeric usage value */
|
|
31
|
+
usageValue: number;
|
|
32
|
+
/** Unit of measurement */
|
|
33
|
+
usageUnit: string;
|
|
34
|
+
/** Estimated cost in USD */
|
|
35
|
+
costUsd: number;
|
|
36
|
+
/** When this data was collected */
|
|
37
|
+
collectedAt: Date;
|
|
38
|
+
/** Period this usage covers */
|
|
39
|
+
periodStart: Date;
|
|
40
|
+
periodEnd: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Provider configuration
|
|
45
|
+
*/
|
|
46
|
+
export interface ProviderConfig {
|
|
47
|
+
/** Provider identifier */
|
|
48
|
+
id: ProviderId;
|
|
49
|
+
/** Human-readable name */
|
|
50
|
+
name: string;
|
|
51
|
+
/** Whether the provider is currently enabled */
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
/** API key environment variable name */
|
|
54
|
+
apiKeyEnvVar: string;
|
|
55
|
+
/** Pricing model description */
|
|
56
|
+
pricingModel: string;
|
|
57
|
+
/** Documentation URL */
|
|
58
|
+
docsUrl: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Provider interface for fetching usage data
|
|
63
|
+
*/
|
|
64
|
+
export interface Provider {
|
|
65
|
+
/** Provider configuration */
|
|
66
|
+
config: ProviderConfig;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetch current usage data from the provider
|
|
70
|
+
* @param apiKey - API key for authentication
|
|
71
|
+
* @param options - Additional fetch options
|
|
72
|
+
* @returns Array of usage records
|
|
73
|
+
*/
|
|
74
|
+
fetchUsage(
|
|
75
|
+
apiKey: string,
|
|
76
|
+
options?: {
|
|
77
|
+
startDate?: Date;
|
|
78
|
+
endDate?: Date;
|
|
79
|
+
includeBreakdown?: boolean;
|
|
80
|
+
}
|
|
81
|
+
): Promise<ThirdPartyUsageRecord[]>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if the provider credentials are valid
|
|
85
|
+
* @param apiKey - API key to validate
|
|
86
|
+
*/
|
|
87
|
+
validateCredentials(apiKey: string): Promise<boolean>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Provider configurations
|
|
92
|
+
*/
|
|
93
|
+
export const PROVIDER_CONFIGS: Record<ProviderId, ProviderConfig> = {
|
|
94
|
+
github: {
|
|
95
|
+
id: 'github',
|
|
96
|
+
name: 'GitHub',
|
|
97
|
+
enabled: true,
|
|
98
|
+
apiKeyEnvVar: 'GITHUB_TOKEN',
|
|
99
|
+
pricingModel: 'Actions minutes, Advanced Security seats, storage',
|
|
100
|
+
docsUrl: 'https://docs.github.com/en/billing',
|
|
101
|
+
},
|
|
102
|
+
openai: {
|
|
103
|
+
id: 'openai',
|
|
104
|
+
name: 'OpenAI',
|
|
105
|
+
enabled: false,
|
|
106
|
+
apiKeyEnvVar: 'OPENAI_ADMIN_API_KEY',
|
|
107
|
+
pricingModel: 'Per-token pricing for API usage',
|
|
108
|
+
docsUrl: 'https://platform.openai.com/docs/api-reference/usage',
|
|
109
|
+
},
|
|
110
|
+
apify: {
|
|
111
|
+
id: 'apify',
|
|
112
|
+
name: 'Apify',
|
|
113
|
+
enabled: false,
|
|
114
|
+
apiKeyEnvVar: 'APIFY_API_KEY',
|
|
115
|
+
pricingModel: 'Compute units for actor runs',
|
|
116
|
+
docsUrl: 'https://docs.apify.com/platform/billing',
|
|
117
|
+
},
|
|
118
|
+
anthropic: {
|
|
119
|
+
id: 'anthropic',
|
|
120
|
+
name: 'Anthropic',
|
|
121
|
+
enabled: false,
|
|
122
|
+
apiKeyEnvVar: 'ANTHROPIC_ADMIN_API_KEY',
|
|
123
|
+
pricingModel: 'Per-token pricing for Claude API',
|
|
124
|
+
docsUrl: 'https://docs.anthropic.com/en/api/admin-api',
|
|
125
|
+
},
|
|
126
|
+
resend: {
|
|
127
|
+
id: 'resend',
|
|
128
|
+
name: 'Resend',
|
|
129
|
+
enabled: false,
|
|
130
|
+
apiKeyEnvVar: 'RESEND_API_KEY',
|
|
131
|
+
pricingModel: 'Per-email pricing',
|
|
132
|
+
docsUrl: 'https://resend.com/docs/api-reference',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// PROVIDER FETCHER STUBS
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// Each stub returns an empty array with a TODO comment.
|
|
140
|
+
// Implement these as integrations are built.
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* GitHub Usage Fetcher
|
|
145
|
+
*
|
|
146
|
+
* TODO: Implement GitHub billing API integration
|
|
147
|
+
* - GET /orgs/{org}/settings/billing/actions
|
|
148
|
+
* - GET /orgs/{org}/settings/billing/advanced-security
|
|
149
|
+
* - GET /orgs/{org}/settings/billing/shared-storage
|
|
150
|
+
*
|
|
151
|
+
* @see https://docs.github.com/en/rest/billing
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchGitHubUsage(
|
|
154
|
+
_apiKey: string,
|
|
155
|
+
_options?: {
|
|
156
|
+
organization?: string;
|
|
157
|
+
startDate?: Date;
|
|
158
|
+
endDate?: Date;
|
|
159
|
+
}
|
|
160
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
161
|
+
// TODO: Implement GitHub billing API calls
|
|
162
|
+
// 1. Fetch Actions minutes usage
|
|
163
|
+
// 2. Fetch Advanced Security seats
|
|
164
|
+
// 3. Fetch shared storage usage
|
|
165
|
+
// 4. Calculate costs based on GitHub pricing
|
|
166
|
+
console.log('[providers] GitHub usage fetcher not yet implemented');
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* OpenAI Usage Fetcher
|
|
172
|
+
*
|
|
173
|
+
* TODO: Implement OpenAI Usage API integration
|
|
174
|
+
* - GET /v1/usage (requires admin API key: sk-admin-...)
|
|
175
|
+
*
|
|
176
|
+
* @see https://platform.openai.com/docs/api-reference/usage
|
|
177
|
+
*/
|
|
178
|
+
export async function fetchOpenAIUsage(
|
|
179
|
+
_apiKey: string,
|
|
180
|
+
_options?: {
|
|
181
|
+
startDate?: Date;
|
|
182
|
+
endDate?: Date;
|
|
183
|
+
}
|
|
184
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
185
|
+
// TODO: Implement OpenAI Usage API calls
|
|
186
|
+
// 1. Fetch token usage by model
|
|
187
|
+
// 2. Calculate costs based on model pricing
|
|
188
|
+
// 3. Group by date for time-series data
|
|
189
|
+
console.log('[providers] OpenAI usage fetcher not yet implemented');
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Apify Usage Fetcher
|
|
195
|
+
*
|
|
196
|
+
* TODO: Implement Apify billing API integration
|
|
197
|
+
* - GET /v2/users/{userId}/usage
|
|
198
|
+
*
|
|
199
|
+
* @see https://docs.apify.com/api/v2#/reference/users/account-usage
|
|
200
|
+
*/
|
|
201
|
+
export async function fetchApifyUsage(
|
|
202
|
+
_apiKey: string,
|
|
203
|
+
_options?: {
|
|
204
|
+
startDate?: Date;
|
|
205
|
+
endDate?: Date;
|
|
206
|
+
}
|
|
207
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
208
|
+
// TODO: Implement Apify usage API calls
|
|
209
|
+
// 1. Fetch compute unit usage
|
|
210
|
+
// 2. Fetch actor run counts
|
|
211
|
+
// 3. Calculate costs based on Apify pricing
|
|
212
|
+
console.log('[providers] Apify usage fetcher not yet implemented');
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Anthropic Usage Fetcher
|
|
218
|
+
*
|
|
219
|
+
* TODO: Implement Anthropic Admin API integration
|
|
220
|
+
* - GET /v1/organizations/{organization_id}/usage (Admin API)
|
|
221
|
+
*
|
|
222
|
+
* @see https://docs.anthropic.com/en/api/admin-api
|
|
223
|
+
*/
|
|
224
|
+
export async function fetchAnthropicUsage(
|
|
225
|
+
_apiKey: string,
|
|
226
|
+
_options?: {
|
|
227
|
+
organizationId?: string;
|
|
228
|
+
startDate?: Date;
|
|
229
|
+
endDate?: Date;
|
|
230
|
+
}
|
|
231
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
232
|
+
// TODO: Implement Anthropic Admin API calls
|
|
233
|
+
// 1. Fetch token usage by model
|
|
234
|
+
// 2. Calculate costs based on Claude pricing
|
|
235
|
+
// 3. Group by date for time-series data
|
|
236
|
+
console.log('[providers] Anthropic usage fetcher not yet implemented');
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resend Usage Fetcher
|
|
242
|
+
*
|
|
243
|
+
* TODO: Implement Resend API integration
|
|
244
|
+
* - No direct usage endpoint; track via webhook or count emails
|
|
245
|
+
*
|
|
246
|
+
* @see https://resend.com/docs/api-reference
|
|
247
|
+
*/
|
|
248
|
+
export async function fetchResendUsage(
|
|
249
|
+
_apiKey: string,
|
|
250
|
+
_options?: {
|
|
251
|
+
startDate?: Date;
|
|
252
|
+
endDate?: Date;
|
|
253
|
+
}
|
|
254
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
255
|
+
// TODO: Implement Resend usage tracking
|
|
256
|
+
// 1. Query sent emails count (if available)
|
|
257
|
+
// 2. Or track via our own email logs
|
|
258
|
+
// 3. Calculate costs based on Resend pricing
|
|
259
|
+
console.log('[providers] Resend usage fetcher not yet implemented');
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// =============================================================================
|
|
264
|
+
// AGGREGATION UTILITIES
|
|
265
|
+
// =============================================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fetch usage from all enabled providers
|
|
269
|
+
*/
|
|
270
|
+
export async function fetchAllProviderUsage(
|
|
271
|
+
apiKeys: Partial<Record<ProviderId, string>>,
|
|
272
|
+
options?: {
|
|
273
|
+
startDate?: Date;
|
|
274
|
+
endDate?: Date;
|
|
275
|
+
}
|
|
276
|
+
): Promise<ThirdPartyUsageRecord[]> {
|
|
277
|
+
const results: ThirdPartyUsageRecord[] = [];
|
|
278
|
+
|
|
279
|
+
// Only fetch from providers that have API keys configured
|
|
280
|
+
const fetchPromises: Promise<ThirdPartyUsageRecord[]>[] = [];
|
|
281
|
+
|
|
282
|
+
if (apiKeys.github) {
|
|
283
|
+
fetchPromises.push(fetchGitHubUsage(apiKeys.github, options));
|
|
284
|
+
}
|
|
285
|
+
if (apiKeys.openai) {
|
|
286
|
+
fetchPromises.push(fetchOpenAIUsage(apiKeys.openai, options));
|
|
287
|
+
}
|
|
288
|
+
if (apiKeys.apify) {
|
|
289
|
+
fetchPromises.push(fetchApifyUsage(apiKeys.apify, options));
|
|
290
|
+
}
|
|
291
|
+
if (apiKeys.anthropic) {
|
|
292
|
+
fetchPromises.push(fetchAnthropicUsage(apiKeys.anthropic, options));
|
|
293
|
+
}
|
|
294
|
+
if (apiKeys.resend) {
|
|
295
|
+
fetchPromises.push(fetchResendUsage(apiKeys.resend, options));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fetch all in parallel, handle individual failures gracefully
|
|
299
|
+
const settledResults = await Promise.allSettled(fetchPromises);
|
|
300
|
+
|
|
301
|
+
for (const result of settledResults) {
|
|
302
|
+
if (result.status === 'fulfilled') {
|
|
303
|
+
results.push(...result.value);
|
|
304
|
+
} else {
|
|
305
|
+
console.error('[providers] Failed to fetch from provider:', result.reason);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return results;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Calculate total third-party costs from usage records
|
|
314
|
+
*/
|
|
315
|
+
export function calculateThirdPartyCosts(records: ThirdPartyUsageRecord[]): {
|
|
316
|
+
byProvider: Record<ProviderId, number>;
|
|
317
|
+
total: number;
|
|
318
|
+
} {
|
|
319
|
+
const byProvider: Partial<Record<ProviderId, number>> = {};
|
|
320
|
+
let total = 0;
|
|
321
|
+
|
|
322
|
+
for (const record of records) {
|
|
323
|
+
byProvider[record.provider] = (byProvider[record.provider] ?? 0) + record.costUsd;
|
|
324
|
+
total += record.costUsd;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
byProvider: byProvider as Record<ProviderId, number>,
|
|
329
|
+
total,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportInfoButton Component
|
|
3
|
+
* Reusable info icon button that toggles a floating panel with report explanation.
|
|
4
|
+
* Based on PatternInfoButton pattern.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
interface ReportInfoButtonProps {
|
|
9
|
+
title: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** Colour theme: matches the original info panel colours per report */
|
|
12
|
+
theme?: 'blue' | 'yellow' | 'orange' | 'red' | 'purple' | 'green' | 'teal';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const themeClasses: Record<string, { button: string; panel: string; heading: string }> = {
|
|
16
|
+
blue: {
|
|
17
|
+
button: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 text-blue-600 dark:text-blue-400',
|
|
18
|
+
panel: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
|
|
19
|
+
heading: 'text-blue-800 dark:text-blue-300',
|
|
20
|
+
},
|
|
21
|
+
yellow: {
|
|
22
|
+
button: 'bg-yellow-100 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 text-yellow-600 dark:text-yellow-400',
|
|
23
|
+
panel: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
|
|
24
|
+
heading: 'text-yellow-800 dark:text-yellow-300',
|
|
25
|
+
},
|
|
26
|
+
orange: {
|
|
27
|
+
button: 'bg-orange-100 hover:bg-orange-200 dark:bg-orange-900/40 dark:hover:bg-orange-900/60 text-orange-600 dark:text-orange-400',
|
|
28
|
+
panel: 'bg-orange-50 dark:bg-orange-900/30 border-orange-200 dark:border-orange-800',
|
|
29
|
+
heading: 'text-orange-800 dark:text-orange-300',
|
|
30
|
+
},
|
|
31
|
+
red: {
|
|
32
|
+
button: 'bg-red-100 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 text-red-600 dark:text-red-400',
|
|
33
|
+
panel: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
|
|
34
|
+
heading: 'text-red-800 dark:text-red-300',
|
|
35
|
+
},
|
|
36
|
+
purple: {
|
|
37
|
+
button: 'bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 text-purple-600 dark:text-purple-400',
|
|
38
|
+
panel: 'bg-purple-50 dark:bg-purple-900/30 border-purple-200 dark:border-purple-800',
|
|
39
|
+
heading: 'text-purple-800 dark:text-purple-300',
|
|
40
|
+
},
|
|
41
|
+
green: {
|
|
42
|
+
button: 'bg-green-100 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 text-green-600 dark:text-green-400',
|
|
43
|
+
panel: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
|
|
44
|
+
heading: 'text-green-800 dark:text-green-300',
|
|
45
|
+
},
|
|
46
|
+
teal: {
|
|
47
|
+
button: 'bg-teal-100 hover:bg-teal-200 dark:bg-teal-900/40 dark:hover:bg-teal-900/60 text-teal-600 dark:text-teal-400',
|
|
48
|
+
panel: 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-800',
|
|
49
|
+
heading: 'text-teal-800 dark:text-teal-300',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function ReportInfoButton({ title, children, theme = 'blue' }: ReportInfoButtonProps) {
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const colours = themeClasses[theme];
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
|
|
61
|
+
function handleClickOutside(event: MouseEvent) {
|
|
62
|
+
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
63
|
+
setOpen(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
68
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
69
|
+
}, [open]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="relative" ref={panelRef}>
|
|
73
|
+
<button
|
|
74
|
+
onClick={() => setOpen(!open)}
|
|
75
|
+
className={`inline-flex items-center justify-center w-9 h-9 rounded-full ${colours.button} transition-colors`}
|
|
76
|
+
title={title}
|
|
77
|
+
aria-label={title}
|
|
78
|
+
>
|
|
79
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
80
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
81
|
+
</svg>
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{open && (
|
|
85
|
+
<div className={`absolute right-0 top-12 z-50 w-96 border rounded-lg p-4 shadow-lg ${colours.panel}`}>
|
|
86
|
+
<h3 className={`font-semibold ${colours.heading} mb-2`}>{title}</h3>
|
|
87
|
+
{children}
|
|
88
|
+
<button
|
|
89
|
+
onClick={() => setOpen(false)}
|
|
90
|
+
className={`mt-3 text-xs ${colours.heading} hover:underline`}
|
|
91
|
+
>
|
|
92
|
+
Close
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DashboardShell Component
|
|
3
|
+
* Industrial Command Center orchestrator - data fetching, state, auto-refresh
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
7
|
+
import { Activity, RefreshCw, AlertTriangle } from 'lucide-react';
|
|
8
|
+
import { clsx } from 'clsx';
|
|
9
|
+
import {
|
|
10
|
+
type Period,
|
|
11
|
+
type UsageRow,
|
|
12
|
+
type ProjectStatus,
|
|
13
|
+
type StatusResponse,
|
|
14
|
+
type QueryResponse,
|
|
15
|
+
} from './types';
|
|
16
|
+
import { UsageChart } from './UsageChart';
|
|
17
|
+
import { UsageTable } from './UsageTable';
|
|
18
|
+
|
|
19
|
+
interface DashboardShellProps {
|
|
20
|
+
initialPeriod?: Period;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const REFRESH_INTERVAL = 60_000; // 60 seconds
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Period button component
|
|
27
|
+
*/
|
|
28
|
+
function PeriodButton({
|
|
29
|
+
period,
|
|
30
|
+
currentPeriod,
|
|
31
|
+
onClick,
|
|
32
|
+
}: {
|
|
33
|
+
period: Period;
|
|
34
|
+
currentPeriod: Period;
|
|
35
|
+
onClick: (p: Period) => void;
|
|
36
|
+
}) {
|
|
37
|
+
const isActive = period === currentPeriod;
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => onClick(period)}
|
|
42
|
+
className={clsx(
|
|
43
|
+
'px-3 py-1.5 text-xs font-mono font-semibold uppercase tracking-wider rounded-sm transition-all',
|
|
44
|
+
isActive
|
|
45
|
+
? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100 shadow-inner'
|
|
46
|
+
: 'bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-800 dark:hover:text-slate-300'
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{period}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Live indicator with pulse
|
|
56
|
+
*/
|
|
57
|
+
function LiveIndicator({ isRefreshing }: { isRefreshing: boolean }) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
{isRefreshing ? (
|
|
61
|
+
<RefreshCw className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 animate-spin" />
|
|
62
|
+
) : (
|
|
63
|
+
<span className="relative flex h-2.5 w-2.5">
|
|
64
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
|
65
|
+
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
<span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
|
|
69
|
+
{isRefreshing ? 'Updating' : 'Live'}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Error state component
|
|
77
|
+
*/
|
|
78
|
+
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="bg-rose-500/10 border border-rose-500/30 rounded-sm p-6 flex items-start gap-4">
|
|
81
|
+
<AlertTriangle className="w-5 h-5 text-rose-400 flex-shrink-0 mt-0.5" />
|
|
82
|
+
<div className="flex-1">
|
|
83
|
+
<h3 className="text-rose-200 font-semibold text-sm">Failed to load data</h3>
|
|
84
|
+
<p className="text-rose-300/80 text-xs mt-1 font-mono">{message}</p>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={onRetry}
|
|
88
|
+
className="mt-3 px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-200 text-xs font-mono rounded-sm transition-colors"
|
|
89
|
+
>
|
|
90
|
+
Retry
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Loading skeleton
|
|
99
|
+
*/
|
|
100
|
+
function LoadingSkeleton() {
|
|
101
|
+
return (
|
|
102
|
+
<div className="space-y-6 animate-pulse">
|
|
103
|
+
{/* Header skeleton */}
|
|
104
|
+
<div className="flex justify-between items-center">
|
|
105
|
+
<div className="h-8 bg-gray-200 dark:bg-slate-800 rounded w-48" />
|
|
106
|
+
<div className="h-8 bg-gray-200 dark:bg-slate-800 rounded w-32" />
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Chart skeleton */}
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
<div className="h-5 bg-gray-200 dark:bg-slate-800 rounded w-36" />
|
|
112
|
+
<div className="h-64 bg-gray-100 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Table skeleton */}
|
|
116
|
+
<div className="space-y-3">
|
|
117
|
+
<div className="h-5 bg-gray-200 dark:bg-slate-800 rounded w-24" />
|
|
118
|
+
<div className="h-48 bg-gray-100 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function DashboardShell({ initialPeriod = '24h' }: DashboardShellProps) {
|
|
125
|
+
const [period, setPeriod] = useState<Period>(initialPeriod);
|
|
126
|
+
const [loading, setLoading] = useState(true);
|
|
127
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
128
|
+
const [error, setError] = useState<string | null>(null);
|
|
129
|
+
const [usageData, setUsageData] = useState<UsageRow[]>([]);
|
|
130
|
+
const [statusMap, setStatusMap] = useState<Record<string, ProjectStatus>>({});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fetch data from both endpoints
|
|
134
|
+
*/
|
|
135
|
+
const fetchData = useCallback(
|
|
136
|
+
async (isBackground = false) => {
|
|
137
|
+
if (isBackground) {
|
|
138
|
+
setIsRefreshing(true);
|
|
139
|
+
} else {
|
|
140
|
+
setLoading(true);
|
|
141
|
+
setError(null);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const [statusRes, queryRes] = await Promise.all([
|
|
146
|
+
fetch(`/api/usage/status?period=${period}`),
|
|
147
|
+
fetch(`/api/usage/query?period=${period}`),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
if (!statusRes.ok) {
|
|
151
|
+
throw new Error(`Status endpoint failed: ${statusRes.status}`);
|
|
152
|
+
}
|
|
153
|
+
if (!queryRes.ok) {
|
|
154
|
+
throw new Error(`Query endpoint failed: ${queryRes.status}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const statusData: StatusResponse = await statusRes.json();
|
|
158
|
+
const queryData: QueryResponse = await queryRes.json();
|
|
159
|
+
|
|
160
|
+
if (!statusData.success || !queryData.success) {
|
|
161
|
+
throw new Error('API returned unsuccessful response');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setStatusMap(statusData.projects);
|
|
165
|
+
setUsageData(queryData.data);
|
|
166
|
+
setError(null);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (!isBackground) {
|
|
169
|
+
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
|
170
|
+
}
|
|
171
|
+
console.error('[DashboardShell] Fetch error:', err);
|
|
172
|
+
} finally {
|
|
173
|
+
setLoading(false);
|
|
174
|
+
setIsRefreshing(false);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
[period]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Handle period change - update URL and refetch
|
|
182
|
+
*/
|
|
183
|
+
const handlePeriodChange = useCallback((newPeriod: Period) => {
|
|
184
|
+
setPeriod(newPeriod);
|
|
185
|
+
|
|
186
|
+
// Update URL without reload
|
|
187
|
+
const url = new URL(window.location.href);
|
|
188
|
+
url.searchParams.set('period', newPeriod);
|
|
189
|
+
history.replaceState(null, '', url.toString());
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
// Initial fetch and period change
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
fetchData();
|
|
195
|
+
}, [fetchData]);
|
|
196
|
+
|
|
197
|
+
// Auto-refresh interval
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const interval = setInterval(() => {
|
|
200
|
+
fetchData(true);
|
|
201
|
+
}, REFRESH_INTERVAL);
|
|
202
|
+
|
|
203
|
+
return () => clearInterval(interval);
|
|
204
|
+
}, [fetchData]);
|
|
205
|
+
|
|
206
|
+
if (loading) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="p-6 bg-gray-50 dark:bg-slate-950 min-h-screen">
|
|
209
|
+
<LoadingSkeleton />
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (error) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="p-6 bg-gray-50 dark:bg-slate-950 min-h-screen">
|
|
217
|
+
<ErrorState message={error} onRetry={() => fetchData()} />
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="p-6 bg-gray-50 dark:bg-slate-950 min-h-screen space-y-6">
|
|
224
|
+
{/* Header */}
|
|
225
|
+
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
226
|
+
<div className="flex items-center gap-3">
|
|
227
|
+
<Activity className="w-5 h-5 text-gray-600 dark:text-slate-400" />
|
|
228
|
+
<h1 className="text-xl font-semibold text-gray-900 dark:text-slate-100 tracking-tight">
|
|
229
|
+
Usage Monitor
|
|
230
|
+
</h1>
|
|
231
|
+
<LiveIndicator isRefreshing={isRefreshing} />
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<PeriodButton period="24h" currentPeriod={period} onClick={handlePeriodChange} />
|
|
236
|
+
<PeriodButton period="7d" currentPeriod={period} onClick={handlePeriodChange} />
|
|
237
|
+
</div>
|
|
238
|
+
</header>
|
|
239
|
+
|
|
240
|
+
{/* Chart section */}
|
|
241
|
+
<section>
|
|
242
|
+
<h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider mb-3">
|
|
243
|
+
Activity Timeline
|
|
244
|
+
</h2>
|
|
245
|
+
<UsageChart data={usageData} loading={isRefreshing && usageData.length === 0} />
|
|
246
|
+
</section>
|
|
247
|
+
|
|
248
|
+
{/* Table section */}
|
|
249
|
+
<section>
|
|
250
|
+
<h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider mb-3">
|
|
251
|
+
Projects
|
|
252
|
+
</h2>
|
|
253
|
+
<UsageTable
|
|
254
|
+
data={usageData}
|
|
255
|
+
statusMap={statusMap}
|
|
256
|
+
loading={isRefreshing && Object.keys(statusMap).length === 0}
|
|
257
|
+
/>
|
|
258
|
+
</section>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default DashboardShell;
|