@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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. 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;