@littlebearapps/platform-admin-sdk 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +4 -7
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +206 -4
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  9. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  10. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  11. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  12. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  13. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  14. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  15. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  16. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  17. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  18. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  19. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  20. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  21. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  22. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  23. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  24. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  25. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  26. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  27. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  28. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  30. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  31. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  32. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  34. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  35. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  36. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  37. package/templates/full/dashboard/src/pages/map.astro +561 -0
  38. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  39. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  40. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  41. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  42. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  43. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  44. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  45. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  46. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  47. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  48. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  49. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  50. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  51. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  52. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  53. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  54. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  55. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  56. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  57. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  58. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  59. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  60. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  61. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  62. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  63. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  64. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  65. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  66. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  67. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  68. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  69. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  70. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  71. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  72. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  73. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  74. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  75. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  76. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  77. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  78. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  79. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  80. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  81. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  82. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  83. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  84. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  85. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  86. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  87. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  88. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  89. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  90. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  91. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  92. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  93. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  94. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  95. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  96. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  97. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  98. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  99. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  100. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  101. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  102. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  103. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  104. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  105. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  106. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  107. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  108. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  109. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  110. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  111. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  112. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  113. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  114. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  115. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  116. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  117. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  118. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  119. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  120. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  121. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  122. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  123. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  124. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  125. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  126. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  127. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  128. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  129. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  130. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  131. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  132. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  133. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  134. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  135. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  136. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  137. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  138. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  139. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  140. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  141. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  142. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  143. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  144. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  145. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  146. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  147. package/templates/shared/tests/unit/billing.test.ts +331 -0
  148. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  149. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  150. package/templates/shared/tests/unit/control.test.ts +226 -0
  151. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  152. package/templates/shared/tests/unit/economics.test.ts +365 -0
  153. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  154. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  155. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  156. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  157. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  158. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  159. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  160. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  161. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  162. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  163. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  164. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  165. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  166. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  167. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  168. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  169. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  170. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  171. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  172. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  173. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  174. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  175. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  176. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  177. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  178. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  179. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  180. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  181. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
  182. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  183. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  184. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  185. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -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
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * ProviderCard — Compact cost card for a single provider
3
+ *
4
+ * Variants:
5
+ * - Allowance-based (Cloudflare, GitHub): progress bar + overage
6
+ * - Pay-as-you-go (OpenAI, Anthropic, Gemini, Apify, Resend): total spend + key metric
7
+ * - Subscription + PAYG (Minimax): subscription cost + API usage
8
+ */
9
+
10
+ // =============================================================================
11
+ // TYPES (match /api/costs/overview response)
12
+ // =============================================================================
13
+
14
+ export interface ModelUsage {
15
+ model: string;
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ }
19
+
20
+ export interface ProviderCostSummary {
21
+ provider: string;
22
+ displayName: string;
23
+ type: 'allowance' | 'pay-as-you-go' | 'subscription-payg' | 'balance';
24
+ totalCostUsd: number;
25
+ subscriptionCostUsd?: number;
26
+ allowanceUsagePct?: number;
27
+ keyMetric?: { label: string; value: string };
28
+ status: 'ok' | 'warning' | 'critical' | 'incomplete';
29
+ lastUpdated: string;
30
+ resources?: ProviderResource[];
31
+ modelBreakdown?: ModelUsage[];
32
+ }
33
+
34
+ export interface ProviderResource {
35
+ name: string;
36
+ label: string;
37
+ used: number;
38
+ allowance?: number;
39
+ pctUsed?: number;
40
+ overageCost?: number;
41
+ costUsd: number;
42
+ unit: string;
43
+ category?: string;
44
+ }
45
+
46
+ // =============================================================================
47
+ // CONSTANTS
48
+ // =============================================================================
49
+
50
+ const PROVIDER_ICONS: Record<string, string> = {
51
+ cloudflare: '☁️',
52
+ github: '🐙',
53
+ openai: '🤖',
54
+ anthropic: '🧠',
55
+ apify: '🕷️',
56
+ resend: '✉️',
57
+ minimax: '🎯',
58
+ gemini: '💎',
59
+ };
60
+
61
+ const PROVIDER_GRADIENTS: Record<string, string> = {
62
+ cloudflare: 'from-orange-500 to-amber-600',
63
+ github: 'from-gray-700 to-gray-900',
64
+ openai: 'from-green-500 to-emerald-600',
65
+ anthropic: 'from-orange-500 to-amber-600',
66
+ apify: 'from-teal-500 to-green-600',
67
+ resend: 'from-violet-500 to-purple-600',
68
+ minimax: 'from-indigo-500 to-blue-600',
69
+ gemini: 'from-blue-400 to-indigo-500',
70
+ };
71
+
72
+ // =============================================================================
73
+ // HELPERS
74
+ // =============================================================================
75
+
76
+ function formatCompact(n: number): string {
77
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
78
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
79
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
80
+ return n.toFixed(0);
81
+ }
82
+
83
+ function barColour(pct: number): string {
84
+ if (pct >= 100) return 'bg-red-500';
85
+ if (pct >= 75) return 'bg-amber-500';
86
+ if (pct >= 50) return 'bg-yellow-400';
87
+ return 'bg-emerald-500';
88
+ }
89
+
90
+ function statusBadge(status: string): { label: string; cls: string } | null {
91
+ if (status === 'critical') return { label: 'OVERAGE', cls: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' };
92
+ if (status === 'warning') return { label: 'HIGH', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' };
93
+ if (status === 'incomplete') return { label: 'PARTIAL', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400' };
94
+ return null;
95
+ }
96
+
97
+ // =============================================================================
98
+ // COMPONENT
99
+ // =============================================================================
100
+
101
+ interface Props {
102
+ provider: ProviderCostSummary;
103
+ selected: boolean;
104
+ onClick: () => void;
105
+ }
106
+
107
+ export function ProviderCard({ provider, selected, onClick }: Props) {
108
+ const icon = PROVIDER_ICONS[provider.provider] ?? '📦';
109
+ const gradient = PROVIDER_GRADIENTS[provider.provider] ?? 'from-gray-500 to-gray-600';
110
+ const badge = statusBadge(provider.status);
111
+
112
+ return (
113
+ <button
114
+ type="button"
115
+ onClick={onClick}
116
+ className={`
117
+ w-full text-left bg-white dark:bg-gray-800 rounded-lg border overflow-hidden
118
+ transition-all duration-150 cursor-pointer
119
+ ${selected
120
+ ? 'border-blue-500 ring-2 ring-blue-500/30 shadow-md'
121
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm'
122
+ }
123
+ `}
124
+ >
125
+ {/* Gradient header */}
126
+ <div className={`bg-gradient-to-r ${gradient} px-3 py-2.5 text-white`}>
127
+ <div className="flex items-center justify-between">
128
+ <div className="flex items-center gap-1.5 min-w-0">
129
+ <span className="text-lg flex-shrink-0">{icon}</span>
130
+ <span className="text-sm font-semibold truncate">{provider.displayName}</span>
131
+ {badge && (
132
+ <span className={`ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold ${badge.cls}`}>
133
+ {badge.label}
134
+ </span>
135
+ )}
136
+ </div>
137
+ <div className="text-right flex-shrink-0 ml-2">
138
+ {provider.type === 'allowance' && provider.subscriptionCostUsd != null && provider.subscriptionCostUsd > 0 ? (
139
+ <div>
140
+ <span className="text-base font-bold">${provider.subscriptionCostUsd.toFixed(2)}</span>
141
+ <span className="text-[9px] opacity-75 block">
142
+ {provider.totalCostUsd > 0 ? `+$${provider.totalCostUsd.toFixed(2)} overage` : '/mo'}
143
+ </span>
144
+ </div>
145
+ ) : (
146
+ <span className="text-base font-bold">${provider.totalCostUsd.toFixed(2)}</span>
147
+ )}
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ {/* Card body */}
153
+ <div className="px-3 py-2.5 space-y-2">
154
+ {provider.type === 'allowance' && <AllowanceBody provider={provider} />}
155
+ {provider.type === 'pay-as-you-go' && <PayAsYouGoBody provider={provider} />}
156
+ {provider.type === 'subscription-payg' && <SubscriptionBody provider={provider} />}
157
+ {provider.type === 'balance' && <BalanceBody provider={provider} />}
158
+
159
+ {/* Footer */}
160
+ <div className="text-[9px] text-gray-400 dark:text-gray-500 pt-1">
161
+ Updated {provider.lastUpdated}
162
+ </div>
163
+ </div>
164
+ </button>
165
+ );
166
+ }
167
+
168
+ // =============================================================================
169
+ // BODY VARIANTS
170
+ // =============================================================================
171
+
172
+ function AllowanceBody({ provider }: { provider: ProviderCostSummary }) {
173
+ const pct = provider.allowanceUsagePct ?? 0;
174
+ const clampedPct = Math.min(pct, 100);
175
+
176
+ return (
177
+ <>
178
+ {/* Subscription line */}
179
+ {provider.subscriptionCostUsd != null && provider.subscriptionCostUsd > 0 && (
180
+ <div className="flex justify-between text-[10px]">
181
+ <span className="text-gray-500 dark:text-gray-400">
182
+ {provider.provider === 'cloudflare' ? 'Workers Paid plan' : 'License'}
183
+ </span>
184
+ <span className="text-gray-600 dark:text-gray-300 font-medium">
185
+ ${provider.subscriptionCostUsd.toFixed(2)}/mo
186
+ </span>
187
+ </div>
188
+ )}
189
+
190
+ {/* Progress bar */}
191
+ <div>
192
+ <div className="flex justify-between text-[10px] mb-0.5">
193
+ <span className="text-gray-500 dark:text-gray-400">Allowance usage</span>
194
+ <span className="text-gray-700 dark:text-gray-300 font-medium">
195
+ {pct >= 1000 ? '999+%' : `${Math.round(pct)}%`}
196
+ </span>
197
+ </div>
198
+ <div className="w-full h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
199
+ <div
200
+ className={`h-full rounded-full transition-all ${barColour(pct)}`}
201
+ style={{ width: `${clampedPct}%` }}
202
+ />
203
+ </div>
204
+ </div>
205
+
206
+ {/* Key metric */}
207
+ {provider.keyMetric && (
208
+ <div className="flex justify-between text-[10px]">
209
+ <span className="text-gray-500 dark:text-gray-400">{provider.keyMetric.label}</span>
210
+ <span className="text-gray-700 dark:text-gray-300 font-medium">{provider.keyMetric.value}</span>
211
+ </div>
212
+ )}
213
+ </>
214
+ );
215
+ }
216
+
217
+ function PayAsYouGoBody({ provider }: { provider: ProviderCostSummary }) {
218
+ return (
219
+ <>
220
+ {provider.keyMetric && (
221
+ <div className="flex justify-between text-[10px]">
222
+ <span className="text-gray-500 dark:text-gray-400">{provider.keyMetric.label}</span>
223
+ <span className="text-gray-700 dark:text-gray-300 font-medium">{provider.keyMetric.value}</span>
224
+ </div>
225
+ )}
226
+ {/* Show top resources compactly */}
227
+ {provider.resources && provider.resources.length > 0 && (
228
+ <div className="space-y-0.5">
229
+ {provider.resources.slice(0, 3).map(r => (
230
+ <div key={r.name} className="flex justify-between text-[9px] text-gray-400 dark:text-gray-500">
231
+ <span className="truncate mr-1">{r.label}</span>
232
+ <span className="flex-shrink-0">{formatCompact(r.used)} {r.unit === 'dollars' ? '' : r.unit}</span>
233
+ </div>
234
+ ))}
235
+ {provider.resources.length > 3 && (
236
+ <span className="text-[9px] text-gray-400">+{provider.resources.length - 3} more</span>
237
+ )}
238
+ </div>
239
+ )}
240
+ </>
241
+ );
242
+ }
243
+
244
+ function SubscriptionBody({ provider }: { provider: ProviderCostSummary }) {
245
+ return (
246
+ <>
247
+ {provider.keyMetric && (
248
+ <div className="flex justify-between text-[10px]">
249
+ <span className="text-gray-500 dark:text-gray-400">{provider.keyMetric.label}</span>
250
+ <span className="text-gray-700 dark:text-gray-300 font-medium">{provider.keyMetric.value}</span>
251
+ </div>
252
+ )}
253
+ <div className="flex justify-between text-[10px]">
254
+ <span className="text-gray-500 dark:text-gray-400">Type</span>
255
+ <span className="text-indigo-600 dark:text-indigo-400 font-medium text-[9px]">Subscription + API</span>
256
+ </div>
257
+ </>
258
+ );
259
+ }
260
+
261
+ function BalanceBody({ provider }: { provider: ProviderCostSummary }) {
262
+ return (
263
+ <>
264
+ {provider.keyMetric && (
265
+ <div className="flex justify-between text-[10px]">
266
+ <span className="text-gray-500 dark:text-gray-400">{provider.keyMetric.label}</span>
267
+ <span className="text-gray-700 dark:text-gray-300 font-medium">{provider.keyMetric.value}</span>
268
+ </div>
269
+ )}
270
+ </>
271
+ );
272
+ }