@littlebearapps/platform-admin-sdk 2.1.0 → 2.3.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 (122) hide show
  1. package/README.md +2 -5
  2. package/dist/check-upgrade.d.ts +29 -0
  3. package/dist/check-upgrade.js +97 -0
  4. package/dist/index.js +59 -4
  5. package/dist/manifest.d.ts +2 -0
  6. package/dist/scaffold.js +5 -1
  7. package/dist/templates.d.ts +6 -1
  8. package/dist/templates.js +141 -3
  9. package/dist/upgrade.d.ts +1 -0
  10. package/dist/upgrade.js +21 -2
  11. package/package.json +1 -1
  12. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  13. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  14. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  15. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  16. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  17. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  18. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  19. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  20. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  21. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  22. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  23. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  24. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  25. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  26. package/templates/full/dashboard/src/pages/map.astro +561 -0
  27. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  28. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  29. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  30. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  31. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  32. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  33. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  34. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  35. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  36. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  37. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  38. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  39. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  40. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  41. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  42. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  43. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  44. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  45. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  46. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  47. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  48. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  49. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  50. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  51. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  52. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  53. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  54. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  55. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  56. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  57. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  58. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  59. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  60. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  61. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  62. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  63. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  64. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  65. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  66. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  67. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  68. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  69. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  70. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  71. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  72. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  73. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  74. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  75. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  76. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  77. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  78. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  79. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  80. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  81. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  82. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  83. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  84. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  85. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  86. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  87. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  88. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  89. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  90. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  91. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  92. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  93. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  94. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  95. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  96. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  97. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  98. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  99. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  100. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  101. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  102. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  103. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  104. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  105. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  107. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  108. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  109. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  110. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  111. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  112. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  113. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  114. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  115. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  116. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  117. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  118. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  119. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  120. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  121. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  122. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Provider Costs Grid Component
3
+ *
4
+ * Displays third-party provider usage and costs in a card grid.
5
+ * Fetches data from /api/costs/providers endpoint.
6
+ *
7
+ * GitHub gets special treatment: subscription fees (GHEC, GHAS) shown
8
+ * separately from usage overage (Actions, Storage).
9
+ */
10
+ import { useState, useEffect } from 'react';
11
+
12
+ // =============================================================================
13
+ // TYPES
14
+ // =============================================================================
15
+
16
+ interface ProviderResource {
17
+ provider: string;
18
+ resourceType: string;
19
+ resourceName?: string;
20
+ usageValue: number;
21
+ usageUnit: string;
22
+ costUsd: number;
23
+ snapshotDate: string;
24
+ label?: string;
25
+ category?: string;
26
+ }
27
+
28
+ interface ProviderSummary {
29
+ provider: string;
30
+ displayName: string;
31
+ totalCostUsd: number;
32
+ resources: ProviderResource[];
33
+ latestSnapshot: string;
34
+ subscriptionCostUsd?: number;
35
+ }
36
+
37
+ interface ApiResponse {
38
+ success: boolean;
39
+ providers?: ProviderSummary[];
40
+ totalCostUsd?: number;
41
+ period?: { startDate: string; endDate: string };
42
+ error?: string;
43
+ }
44
+
45
+ interface Props {
46
+ period: string;
47
+ }
48
+
49
+ // =============================================================================
50
+ // CONSTANTS
51
+ // =============================================================================
52
+
53
+ const PROVIDER_ICONS: Record<string, string> = {
54
+ github: '🐙',
55
+ openai: '🤖',
56
+ anthropic: '🧠',
57
+ apify: '🕷️',
58
+ resend: '✉️',
59
+ minimax: '🎯',
60
+ gemini: '💎',
61
+ };
62
+
63
+ const PROVIDER_GRADIENTS: Record<string, string> = {
64
+ github: 'from-gray-700 to-gray-900',
65
+ openai: 'from-green-500 to-emerald-600',
66
+ anthropic: 'from-orange-500 to-amber-600',
67
+ apify: 'from-teal-500 to-green-600',
68
+ resend: 'from-violet-500 to-purple-600',
69
+ minimax: 'from-indigo-500 to-blue-600',
70
+ gemini: 'from-blue-400 to-indigo-500',
71
+ };
72
+
73
+ const PERIOD_LABELS: Record<string, string> = {
74
+ '24h': 'Today',
75
+ '7d': 'Last 7 days',
76
+ '30d': 'Last 30 days',
77
+ };
78
+
79
+ // =============================================================================
80
+ // HELPERS
81
+ // =============================================================================
82
+
83
+ function formatResourceLabel(resource: ProviderResource): string {
84
+ if (resource.label) return resource.label;
85
+ // Fallback: convert snake_case to Title Case
86
+ return resource.resourceType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
87
+ }
88
+
89
+ function formatValue(value: number, unit: string): string {
90
+ if (unit === 'dollars' || unit === 'usd') {
91
+ return `$${value.toFixed(2)}`;
92
+ }
93
+ if (unit === 'tokens') {
94
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M tokens`;
95
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K tokens`;
96
+ return `${value.toLocaleString()} tokens`;
97
+ }
98
+ if (unit === 'minutes') {
99
+ return `${value.toLocaleString()} min`;
100
+ }
101
+ if (unit === 'requests') {
102
+ return `${value.toLocaleString()} req`;
103
+ }
104
+ if (unit === 'user_months') {
105
+ return `${value.toFixed(1)} user-mo`;
106
+ }
107
+ if (unit === 'seats') {
108
+ return `${value.toLocaleString()} seats`;
109
+ }
110
+ if (unit === 'gb_hours') {
111
+ return `${value.toFixed(1)} GB-hr`;
112
+ }
113
+ if (unit === 'percent') {
114
+ return `${value.toFixed(1)}%`;
115
+ }
116
+ if (unit === 'bytes') {
117
+ if (value > 1024 * 1024 * 1024) return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
118
+ if (value > 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
119
+ return `${(value / 1024).toFixed(2)} KB`;
120
+ }
121
+ if (unit === 'gb') {
122
+ return `${value.toFixed(2)} GB`;
123
+ }
124
+ if (unit === 'units') {
125
+ return `${value.toFixed(2)} units`;
126
+ }
127
+ return `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`;
128
+ }
129
+
130
+ function formatDate(dateStr: string): string {
131
+ const date = new Date(dateStr);
132
+ return date.toLocaleDateString('en-AU', {
133
+ day: 'numeric',
134
+ month: 'short',
135
+ year: 'numeric',
136
+ });
137
+ }
138
+
139
+ // =============================================================================
140
+ // COMPONENTS
141
+ // =============================================================================
142
+
143
+ function LoadingState() {
144
+ return (
145
+ <div className="space-y-4">
146
+ <div className="animate-pulse">
147
+ <div className="h-24 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4" />
148
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
149
+ <div className="h-48 bg-gray-200 dark:bg-gray-700 rounded-lg" />
150
+ <div className="h-48 bg-gray-200 dark:bg-gray-700 rounded-lg" />
151
+ <div className="h-48 bg-gray-200 dark:bg-gray-700 rounded-lg" />
152
+ </div>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function ErrorState({ message }: { message: string }) {
159
+ return (
160
+ <div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
161
+ <div className="flex items-center gap-3">
162
+ <span className="text-xl">⚠️</span>
163
+ <div>
164
+ <strong className="text-red-800 dark:text-red-200">Error loading data</strong>
165
+ <p className="text-sm text-red-600 dark:text-red-300 mt-1">{message}</p>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function EmptyState() {
173
+ return (
174
+ <div className="text-center py-12">
175
+ <div className="text-6xl mb-4">📊</div>
176
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">No provider data</h3>
177
+ <p className="text-gray-500 dark:text-gray-400 mt-1">
178
+ Third-party usage data will appear here once collected.
179
+ </p>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ function TotalCostHero({ totalCost, period }: { totalCost: number; period: string }) {
185
+ return (
186
+ <div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-xl p-6 text-white">
187
+ <div className="flex items-center justify-between">
188
+ <div>
189
+ <p className="text-purple-100 text-sm font-medium">Total Third-Party Usage Costs</p>
190
+ <p className="text-4xl font-bold mt-1">${totalCost.toFixed(2)}</p>
191
+ <p className="text-purple-200 text-sm mt-2">
192
+ {PERIOD_LABELS[period] || 'Last 30 days'} — excludes fixed subscriptions
193
+ </p>
194
+ </div>
195
+ <div className="text-6xl opacity-20">💳</div>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function ResourceRow({ resource }: { resource: ProviderResource }) {
202
+ const isSubscription = resource.category === 'subscription';
203
+
204
+ return (
205
+ <div className="flex justify-between items-center text-sm py-1.5">
206
+ <span className="text-gray-600 dark:text-gray-400 truncate mr-2 flex items-center gap-1.5">
207
+ {isSubscription && (
208
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
209
+ LICENSE
210
+ </span>
211
+ )}
212
+ {formatResourceLabel(resource)}
213
+ </span>
214
+ <div className="flex items-center gap-2 whitespace-nowrap">
215
+ <span className="text-gray-900 dark:text-white font-medium">
216
+ {formatValue(resource.usageValue, resource.usageUnit)}
217
+ </span>
218
+ {resource.costUsd > 0 && (
219
+ <span className="text-gray-400 dark:text-gray-500 text-xs">
220
+ (${resource.costUsd.toFixed(2)})
221
+ </span>
222
+ )}
223
+ </div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ function GitHubCard({ provider }: { provider: ProviderSummary }) {
229
+ const icon = PROVIDER_ICONS.github;
230
+ const gradient = PROVIDER_GRADIENTS.github;
231
+ const subscriptionResources = provider.resources.filter((r) => r.category === 'subscription');
232
+ const usageResources = provider.resources.filter((r) => r.category !== 'subscription');
233
+
234
+ return (
235
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
236
+ {/* Header */}
237
+ <div className={`bg-gradient-to-r ${gradient} p-4 text-white`}>
238
+ <div className="flex items-center justify-between">
239
+ <div className="flex items-center gap-2">
240
+ <span className="text-2xl">{icon}</span>
241
+ <span className="font-semibold">{provider.displayName}</span>
242
+ </div>
243
+ <div className="text-right">
244
+ <span className="text-xl font-bold">${provider.totalCostUsd.toFixed(2)}</span>
245
+ <p className="text-xs text-gray-300 mt-0.5">usage overage</p>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ <div className="p-4 space-y-3">
251
+ {/* Usage section */}
252
+ {usageResources.length > 0 && (
253
+ <div>
254
+ <p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">
255
+ Usage (above allowances)
256
+ </p>
257
+ <div className="divide-y divide-gray-100 dark:divide-gray-700">
258
+ {usageResources.map((r, idx) => (
259
+ <ResourceRow key={idx} resource={r} />
260
+ ))}
261
+ </div>
262
+ </div>
263
+ )}
264
+
265
+ {/* Subscription section */}
266
+ {subscriptionResources.length > 0 && (
267
+ <div className="pt-2 border-t border-gray-200 dark:border-gray-700">
268
+ <div className="flex justify-between items-center mb-1">
269
+ <p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
270
+ Subscriptions (fixed monthly)
271
+ </p>
272
+ {provider.subscriptionCostUsd != null && provider.subscriptionCostUsd > 0 && (
273
+ <span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
274
+ ${provider.subscriptionCostUsd.toFixed(2)}/mo
275
+ </span>
276
+ )}
277
+ </div>
278
+ <div className="divide-y divide-gray-100 dark:divide-gray-700">
279
+ {subscriptionResources.map((r, idx) => (
280
+ <ResourceRow key={idx} resource={r} />
281
+ ))}
282
+ </div>
283
+ </div>
284
+ )}
285
+
286
+ <p className="text-xs text-gray-400 mt-2">
287
+ Last updated: {formatDate(provider.latestSnapshot)}
288
+ </p>
289
+ </div>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function StandardCard({ provider }: { provider: ProviderSummary }) {
295
+ const icon = PROVIDER_ICONS[provider.provider] || '📦';
296
+ const gradient = PROVIDER_GRADIENTS[provider.provider] || 'from-gray-500 to-gray-600';
297
+ const displayResources = provider.resources.slice(0, 6);
298
+ const moreCount = provider.resources.length - 6;
299
+
300
+ return (
301
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
302
+ {/* Header with gradient */}
303
+ <div className={`bg-gradient-to-r ${gradient} p-4 text-white`}>
304
+ <div className="flex items-center justify-between">
305
+ <div className="flex items-center gap-2">
306
+ <span className="text-2xl">{icon}</span>
307
+ <span className="font-semibold">{provider.displayName}</span>
308
+ </div>
309
+ <span className="text-xl font-bold">${provider.totalCostUsd.toFixed(2)}</span>
310
+ </div>
311
+ </div>
312
+
313
+ {/* Resources */}
314
+ <div className="p-4">
315
+ <div className="divide-y divide-gray-100 dark:divide-gray-700">
316
+ {displayResources.map((r, idx) => (
317
+ <ResourceRow key={idx} resource={r} />
318
+ ))}
319
+ </div>
320
+ {moreCount > 0 && (
321
+ <p className="text-xs text-gray-400 mt-1">+{moreCount} more resources</p>
322
+ )}
323
+ <p className="text-xs text-gray-400 mt-3">
324
+ Last updated: {formatDate(provider.latestSnapshot)}
325
+ </p>
326
+ </div>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ function ProviderCard({ provider }: { provider: ProviderSummary }) {
332
+ if (provider.provider === 'github') {
333
+ return <GitHubCard provider={provider} />;
334
+ }
335
+ return <StandardCard provider={provider} />;
336
+ }
337
+
338
+ // =============================================================================
339
+ // MAIN COMPONENT
340
+ // =============================================================================
341
+
342
+ export default function ProviderCostsGrid({ period }: Props) {
343
+ const [loading, setLoading] = useState(true);
344
+ const [error, setError] = useState<string | null>(null);
345
+ const [providers, setProviders] = useState<ProviderSummary[]>([]);
346
+ const [totalCost, setTotalCost] = useState(0);
347
+
348
+ useEffect(() => {
349
+ async function fetchData() {
350
+ setLoading(true);
351
+ setError(null);
352
+
353
+ try {
354
+ const response = await fetch(`/api/costs/providers?period=${period}`);
355
+ const data: ApiResponse = await response.json();
356
+
357
+ if (!data.success) {
358
+ throw new Error(data.error || 'Failed to load data');
359
+ }
360
+
361
+ setProviders(data.providers || []);
362
+ setTotalCost(data.totalCostUsd || 0);
363
+ } catch (err) {
364
+ setError(err instanceof Error ? err.message : 'Unknown error');
365
+ } finally {
366
+ setLoading(false);
367
+ }
368
+ }
369
+
370
+ fetchData();
371
+ }, [period]);
372
+
373
+ if (loading) {
374
+ return <LoadingState />;
375
+ }
376
+
377
+ if (error) {
378
+ return <ErrorState message={error} />;
379
+ }
380
+
381
+ if (providers.length === 0) {
382
+ return (
383
+ <div className="space-y-6">
384
+ <TotalCostHero totalCost={0} period={period} />
385
+ <EmptyState />
386
+ </div>
387
+ );
388
+ }
389
+
390
+ return (
391
+ <div className="space-y-6">
392
+ <TotalCostHero totalCost={totalCost} period={period} />
393
+
394
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
395
+ {providers.map((provider) => (
396
+ <ProviderCard key={provider.provider} provider={provider} />
397
+ ))}
398
+ </div>
399
+ </div>
400
+ );
401
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Costs Components Export
3
+ */
4
+ export { default as ProviderCostsGrid } from './ProviderCostsGrid';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Global Alert Banner
3
+ * Displays at the top of every page when there are critical issues:
4
+ * - P0/P1 open errors
5
+ * - Tripped circuit breakers
6
+ * - Services down (Gatus)
7
+ */
8
+ import { useState, useEffect } from 'react';
9
+
10
+ interface AlertData {
11
+ hasP0P1: boolean;
12
+ trippedBreakers: number;
13
+ servicesDown: number;
14
+ p0Count?: number;
15
+ p1Count?: number;
16
+ }
17
+
18
+ export function AlertBanner() {
19
+ const [alerts, setAlerts] = useState<AlertData | null>(null);
20
+
21
+ useEffect(() => {
22
+ fetch('/api/overview/summary')
23
+ .then(res => res.json())
24
+ .then(data => {
25
+ if (data.alerts) {
26
+ setAlerts({
27
+ ...data.alerts,
28
+ p0Count: data.errors?.p0Count ?? 0,
29
+ p1Count: data.errors?.p1Count ?? 0,
30
+ });
31
+ }
32
+ })
33
+ .catch(() => {
34
+ // Silently fail - banner just won't show
35
+ });
36
+ }, []);
37
+
38
+ if (!alerts) return null;
39
+
40
+ const hasIssues = alerts.hasP0P1 || alerts.trippedBreakers > 0 || alerts.servicesDown > 0;
41
+ if (!hasIssues) return null;
42
+
43
+ const items: Array<{ label: string; count: number; href: string; colour: string }> = [];
44
+
45
+ if (alerts.p0Count && alerts.p0Count > 0) {
46
+ items.push({
47
+ label: 'P0 error',
48
+ count: alerts.p0Count,
49
+ href: '/health?tab=errors',
50
+ colour: 'bg-red-500',
51
+ });
52
+ }
53
+ if (alerts.p1Count && alerts.p1Count > 0) {
54
+ items.push({
55
+ label: 'P1 error',
56
+ count: alerts.p1Count,
57
+ href: '/health?tab=errors',
58
+ colour: 'bg-orange-500',
59
+ });
60
+ }
61
+ if (alerts.trippedBreakers > 0) {
62
+ items.push({
63
+ label: 'tripped breaker',
64
+ count: alerts.trippedBreakers,
65
+ href: '/resources?tab=features',
66
+ colour: 'bg-yellow-500',
67
+ });
68
+ }
69
+ if (alerts.servicesDown > 0) {
70
+ items.push({
71
+ label: 'service down',
72
+ count: alerts.servicesDown,
73
+ href: '/health?tab=infrastructure',
74
+ colour: 'bg-red-500',
75
+ });
76
+ }
77
+
78
+ return (
79
+ <div className="bg-red-600 dark:bg-red-900 text-white px-4 py-2 text-sm flex items-center justify-center gap-4 flex-wrap">
80
+ <span className="font-semibold">Attention Required</span>
81
+ {items.map((item, i) => (
82
+ <a
83
+ key={i}
84
+ href={item.href}
85
+ className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
86
+ >
87
+ <span className={`w-2 h-2 rounded-full ${item.colour} animate-pulse`} />
88
+ {item.count} {item.label}{item.count !== 1 ? 's' : ''}
89
+ <span className="opacity-75">→</span>
90
+ </a>
91
+ ))}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Overview Components Barrel Export
3
+ */
4
+ export { MissionControl } from './MissionControl';
5
+ export { AlertBanner } from './AlertBanner';
6
+ export { HealthQuadrant } from './HealthQuadrant';
7
+ export { ErrorsQuadrant } from './ErrorsQuadrant';
8
+ export { CostQuadrant } from './CostQuadrant';
9
+ export { ActivityFeed } from './ActivityFeed';
@@ -0,0 +1,170 @@
1
+ /**
2
+ * CostChart — Stacked bar chart for daily costs by provider
3
+ *
4
+ * Pure CSS bars (no chart library). Shows daily cost breakdown across all
5
+ * providers, or filtered to a single provider when a card is selected.
6
+ * Matches existing HourlyUsageChart pattern.
7
+ */
8
+
9
+ // =============================================================================
10
+ // TYPES
11
+ // =============================================================================
12
+
13
+ export interface ChartDataPoint {
14
+ date: string;
15
+ providers: Record<string, number>;
16
+ }
17
+
18
+ interface Props {
19
+ data: ChartDataPoint[];
20
+ selectedProvider: string | null;
21
+ providerColours: Record<string, string>;
22
+ }
23
+
24
+ // =============================================================================
25
+ // CONSTANTS
26
+ // =============================================================================
27
+
28
+ const DEFAULT_COLOURS: Record<string, string> = {
29
+ cloudflare: '#f97316', // orange-500
30
+ github: '#6b7280', // gray-500
31
+ openai: '#10b981', // emerald-500
32
+ anthropic: '#f59e0b', // amber-500
33
+ gemini: '#6366f1', // indigo-500
34
+ apify: '#14b8a6', // teal-500
35
+ resend: '#8b5cf6', // violet-500
36
+ minimax: '#3b82f6', // blue-500
37
+ };
38
+
39
+ // =============================================================================
40
+ // HELPERS
41
+ // =============================================================================
42
+
43
+ function formatDateLabel(dateStr: string): string {
44
+ const d = new Date(dateStr + 'T00:00:00Z');
45
+ return d.toLocaleDateString('en-AU', { day: 'numeric', month: 'short' });
46
+ }
47
+
48
+ // =============================================================================
49
+ // COMPONENT
50
+ // =============================================================================
51
+
52
+ export function CostChart({ data, selectedProvider, providerColours }: Props) {
53
+ const colours = { ...DEFAULT_COLOURS, ...providerColours };
54
+
55
+ if (data.length === 0) {
56
+ return (
57
+ <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
58
+ <p className="text-sm text-gray-500 dark:text-gray-400">No chart data available for this period.</p>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ // Compute max total for scaling
64
+ const barData = data.map(point => {
65
+ const entries = Object.entries(point.providers);
66
+ const filtered = selectedProvider
67
+ ? entries.filter(([p]) => p === selectedProvider)
68
+ : entries;
69
+ const total = filtered.reduce((s, [, v]) => s + v, 0);
70
+ return { date: point.date, segments: filtered, total };
71
+ });
72
+
73
+ const maxTotal = Math.max(...barData.map(d => d.total), 0.01);
74
+
75
+ // Determine label density (show every Nth label to avoid crowding)
76
+ const labelStep = data.length > 30 ? 7 : data.length > 14 ? 3 : data.length > 7 ? 2 : 1;
77
+
78
+ // Collect all providers for legend
79
+ const allProviders = new Set<string>();
80
+ for (const point of data) {
81
+ for (const p of Object.keys(point.providers)) {
82
+ if (!selectedProvider || p === selectedProvider) allProviders.add(p);
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
88
+ {/* Legend */}
89
+ <div className="flex flex-wrap gap-3 mb-3">
90
+ {Array.from(allProviders).map(p => (
91
+ <div key={p} className="flex items-center gap-1.5">
92
+ <div
93
+ className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
94
+ style={{ backgroundColor: colours[p] ?? '#9ca3af' }}
95
+ />
96
+ <span className="text-[10px] text-gray-600 dark:text-gray-400 capitalize">{p}</span>
97
+ </div>
98
+ ))}
99
+ </div>
100
+
101
+ {/* Y-axis max label */}
102
+ <div className="flex items-end mb-1">
103
+ <span className="text-[9px] text-gray-400 dark:text-gray-500 w-12 text-right mr-2">
104
+ ${maxTotal.toFixed(2)}
105
+ </span>
106
+ <div className="flex-1 border-b border-dashed border-gray-200 dark:border-gray-700" />
107
+ </div>
108
+
109
+ {/* Bars */}
110
+ <div className="flex items-end gap-px" style={{ height: '120px' }}>
111
+ {barData.map((bar, idx) => {
112
+ const heightPct = maxTotal > 0 ? (bar.total / maxTotal) * 100 : 0;
113
+ return (
114
+ <div key={bar.date} className="flex-1 flex flex-col justify-end h-full group relative">
115
+ {/* Stacked bar */}
116
+ <div
117
+ className="w-full rounded-t-sm overflow-hidden flex flex-col-reverse transition-all"
118
+ style={{ height: `${heightPct}%`, minHeight: bar.total > 0 ? '2px' : '0px' }}
119
+ >
120
+ {bar.segments.map(([provider, cost]) => {
121
+ const segPct = bar.total > 0 ? (cost / bar.total) * 100 : 0;
122
+ return (
123
+ <div
124
+ key={provider}
125
+ style={{
126
+ height: `${segPct}%`,
127
+ backgroundColor: colours[provider] ?? '#9ca3af',
128
+ minHeight: cost > 0 ? '1px' : '0px',
129
+ }}
130
+ />
131
+ );
132
+ })}
133
+ </div>
134
+
135
+ {/* Tooltip */}
136
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
137
+ <div className="bg-gray-900 text-white text-[10px] rounded px-2 py-1.5 whitespace-nowrap shadow-lg">
138
+ <div className="font-medium mb-0.5">{formatDateLabel(bar.date)}</div>
139
+ {bar.segments.map(([p, cost]) => (
140
+ <div key={p} className="flex items-center gap-1">
141
+ <div className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colours[p] }} />
142
+ <span className="capitalize">{p}:</span>
143
+ <span className="font-medium">${cost.toFixed(2)}</span>
144
+ </div>
145
+ ))}
146
+ <div className="border-t border-gray-700 mt-0.5 pt-0.5 font-medium">
147
+ Total: ${bar.total.toFixed(2)}
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ {/* X-axis label (sparse) */}
153
+ {idx % labelStep === 0 && (
154
+ <span className="text-[8px] text-gray-400 dark:text-gray-500 mt-1 text-center block truncate">
155
+ {formatDateLabel(bar.date)}
156
+ </span>
157
+ )}
158
+ </div>
159
+ );
160
+ })}
161
+ </div>
162
+
163
+ {/* Zero line */}
164
+ <div className="flex items-start mt-0.5">
165
+ <span className="text-[9px] text-gray-400 dark:text-gray-500 w-12 text-right mr-2">$0</span>
166
+ <div className="flex-1 border-t border-gray-200 dark:border-gray-700" />
167
+ </div>
168
+ </div>
169
+ );
170
+ }