@littlebearapps/platform-admin-sdk 2.1.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 (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  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/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -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
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * ProviderDetail — Expandable detail panel for a selected provider
3
+ *
4
+ * Shows per-service/resource breakdown with:
5
+ * - Cloudflare: allowance bars per service (D1, KV, R2, DO, etc.)
6
+ * - GitHub: subscription vs usage overage breakdown
7
+ * - Others: resource-level detail with usage values and costs
8
+ */
9
+ import type { ProviderCostSummary, ProviderResource, ModelUsage } from './ProviderCard';
10
+
11
+ // =============================================================================
12
+ // HELPERS
13
+ // =============================================================================
14
+
15
+ function formatCompact(n: number): string {
16
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
17
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
18
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
19
+ return n.toFixed(0);
20
+ }
21
+
22
+ function barColour(pct: number): string {
23
+ if (pct >= 100) return 'bg-red-500';
24
+ if (pct >= 75) return 'bg-amber-500';
25
+ if (pct >= 50) return 'bg-yellow-400';
26
+ return 'bg-emerald-500';
27
+ }
28
+
29
+ // =============================================================================
30
+ // COMPONENT
31
+ // =============================================================================
32
+
33
+ interface Props {
34
+ provider: ProviderCostSummary;
35
+ }
36
+
37
+ export function ProviderDetail({ provider }: Props) {
38
+ if (!provider.resources || provider.resources.length === 0) {
39
+ return (
40
+ <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
41
+ <p className="text-sm text-gray-500 dark:text-gray-400">
42
+ No detailed resource data available for {provider.displayName}.
43
+ </p>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-in slide-in-from-top-2">
50
+ <div className="flex items-center justify-between mb-3">
51
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
52
+ {provider.displayName} — Service Breakdown
53
+ </h3>
54
+ <div className="flex items-center gap-3 text-xs font-medium text-gray-500 dark:text-gray-400">
55
+ {provider.subscriptionCostUsd != null && provider.subscriptionCostUsd > 0 && (
56
+ <span>License: ${provider.subscriptionCostUsd.toFixed(2)}/mo</span>
57
+ )}
58
+ {provider.totalCostUsd > 0 && (
59
+ <span className="text-red-600 dark:text-red-400">Overage: ${provider.totalCostUsd.toFixed(2)}</span>
60
+ )}
61
+ {provider.totalCostUsd === 0 && !provider.subscriptionCostUsd && (
62
+ <span>$0.00</span>
63
+ )}
64
+ </div>
65
+ </div>
66
+
67
+ {provider.type === 'allowance' && provider.provider === 'cloudflare' && (
68
+ <CloudflareDetail resources={provider.resources} />
69
+ )}
70
+ {provider.provider === 'github' && (
71
+ <GitHubDetail resources={provider.resources} subscriptionCost={provider.subscriptionCostUsd} />
72
+ )}
73
+ {provider.type === 'pay-as-you-go' && provider.provider !== 'github' && (
74
+ <GenericDetail resources={provider.resources} modelBreakdown={provider.modelBreakdown} />
75
+ )}
76
+ {provider.type === 'subscription-payg' && (
77
+ <GenericDetail resources={provider.resources} modelBreakdown={provider.modelBreakdown} />
78
+ )}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ // =============================================================================
84
+ // CLOUDFLARE DETAIL — Allowance bars per service
85
+ // =============================================================================
86
+
87
+ function CloudflareDetail({ resources }: { resources: ProviderResource[] }) {
88
+ return (
89
+ <div className="space-y-3">
90
+ {resources.map(r => {
91
+ const hasAllowance = r.allowance != null && r.allowance > 0;
92
+ const pct = r.pctUsed ?? 0;
93
+ const clampedPct = Math.min(pct, 100);
94
+
95
+ return (
96
+ <div key={r.name}>
97
+ <div className="flex items-center justify-between mb-1">
98
+ <span className="text-xs font-medium text-gray-700 dark:text-gray-300">{r.label}</span>
99
+ <div className="flex items-center gap-2">
100
+ {hasAllowance && (
101
+ <span className="text-[10px] text-gray-500 dark:text-gray-400">
102
+ {formatCompact(r.used)} / {formatCompact(r.allowance!)}
103
+ </span>
104
+ )}
105
+ {r.overageCost != null && r.overageCost > 0 ? (
106
+ <span className="text-[10px] font-medium text-red-600 dark:text-red-400">
107
+ +${r.overageCost.toFixed(2)}
108
+ </span>
109
+ ) : (
110
+ <span className="text-[10px] text-emerald-500">$0.00</span>
111
+ )}
112
+ </div>
113
+ </div>
114
+ {hasAllowance && (
115
+ <div className="w-full h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
116
+ <div
117
+ className={`h-full rounded-full transition-all ${barColour(pct)}`}
118
+ style={{ width: `${clampedPct}%` }}
119
+ />
120
+ </div>
121
+ )}
122
+ </div>
123
+ );
124
+ })}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // =============================================================================
130
+ // GITHUB DETAIL — Subscription vs usage
131
+ // =============================================================================
132
+
133
+ function GitHubDetail({ resources, subscriptionCost }: { resources: ProviderResource[]; subscriptionCost?: number }) {
134
+ const subs = resources.filter(r => r.category === 'subscription');
135
+ const usage = resources.filter(r => r.category === 'usage');
136
+
137
+ return (
138
+ <div className="space-y-4">
139
+ {/* Usage vs allowance section (like Cloudflare) */}
140
+ {usage.length > 0 && (
141
+ <div>
142
+ <p className="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
143
+ Usage vs Enterprise Allowances
144
+ </p>
145
+ <div className="space-y-3">
146
+ {usage.map(r => {
147
+ const hasAllowance = r.allowance != null && r.allowance > 0;
148
+ const pct = r.pctUsed ?? 0;
149
+ const clampedPct = Math.min(pct, 100);
150
+
151
+ return (
152
+ <div key={r.name}>
153
+ <div className="flex items-center justify-between mb-1">
154
+ <span className="text-xs font-medium text-gray-700 dark:text-gray-300">{r.label}</span>
155
+ <div className="flex items-center gap-2">
156
+ {hasAllowance && (
157
+ <span className="text-[10px] text-gray-500 dark:text-gray-400">
158
+ {formatCompact(r.used)} / {formatCompact(r.allowance!)} {r.unit}
159
+ </span>
160
+ )}
161
+ {r.overageCost != null && r.overageCost > 0 ? (
162
+ <span className="text-[10px] font-medium text-red-600 dark:text-red-400">
163
+ +${r.overageCost.toFixed(2)}
164
+ </span>
165
+ ) : (
166
+ <span className="text-[10px] text-emerald-500">Within allowance</span>
167
+ )}
168
+ </div>
169
+ </div>
170
+ {hasAllowance && (
171
+ <div className="w-full h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
172
+ <div
173
+ className={`h-full rounded-full transition-all ${barColour(pct)}`}
174
+ style={{ width: `${clampedPct}%` }}
175
+ />
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ })}
181
+ </div>
182
+ </div>
183
+ )}
184
+
185
+ {/* Subscription section */}
186
+ {subs.length > 0 && (
187
+ <div className="pt-2 border-t border-gray-200 dark:border-gray-700">
188
+ <div className="flex justify-between items-center mb-1.5">
189
+ <p className="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
190
+ Subscriptions (fixed monthly)
191
+ </p>
192
+ {subscriptionCost != null && subscriptionCost > 0 && (
193
+ <span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
194
+ ${subscriptionCost.toFixed(2)}/mo
195
+ </span>
196
+ )}
197
+ </div>
198
+ <div className="space-y-1.5">
199
+ {subs.map(r => (
200
+ <ResourceRow key={r.name} resource={r} showLicenseBadge />
201
+ ))}
202
+ </div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ // =============================================================================
210
+ // GENERIC DETAIL — Simple resource list
211
+ // =============================================================================
212
+
213
+ function GenericDetail({ resources, modelBreakdown }: { resources: ProviderResource[]; modelBreakdown?: ModelUsage[] }) {
214
+ return (
215
+ <div className="space-y-1.5">
216
+ {resources.map(r => (
217
+ <ResourceRow key={r.name} resource={r} />
218
+ ))}
219
+ {modelBreakdown && modelBreakdown.length > 0 && (
220
+ <ModelBreakdownSection models={modelBreakdown} />
221
+ )}
222
+ </div>
223
+ );
224
+ }
225
+
226
+ // =============================================================================
227
+ // MODEL BREAKDOWN — Per-model token usage table
228
+ // =============================================================================
229
+
230
+ function ModelBreakdownSection({ models }: { models: ModelUsage[] }) {
231
+ return (
232
+ <div className="pt-2 mt-1 border-t border-gray-200 dark:border-gray-700">
233
+ <p className="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
234
+ Model Breakdown
235
+ </p>
236
+ <div className="space-y-1">
237
+ {/* Header */}
238
+ <div className="flex text-[9px] text-gray-400 dark:text-gray-500 font-medium">
239
+ <span className="flex-1">Model</span>
240
+ <span className="w-16 text-right">Input</span>
241
+ <span className="w-16 text-right">Output</span>
242
+ <span className="w-16 text-right">Total</span>
243
+ </div>
244
+ {/* Rows */}
245
+ {models.map(m => (
246
+ <div key={m.model} className="flex text-[10px] items-center">
247
+ <span className="flex-1 text-gray-700 dark:text-gray-300 font-medium truncate mr-1">
248
+ {m.model}
249
+ </span>
250
+ <span className="w-16 text-right text-gray-500 dark:text-gray-400">
251
+ {formatCompact(m.inputTokens)}
252
+ </span>
253
+ <span className="w-16 text-right text-gray-500 dark:text-gray-400">
254
+ {formatCompact(m.outputTokens)}
255
+ </span>
256
+ <span className="w-16 text-right text-gray-700 dark:text-gray-300 font-medium">
257
+ {formatCompact(m.inputTokens + m.outputTokens)}
258
+ </span>
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ // =============================================================================
267
+ // RESOURCE ROW
268
+ // =============================================================================
269
+
270
+ function ResourceRow({ resource, showLicenseBadge }: { resource: ProviderResource; showLicenseBadge?: boolean }) {
271
+ return (
272
+ <div className="flex justify-between items-center text-xs py-1">
273
+ <span className="text-gray-600 dark:text-gray-400 truncate mr-2 flex items-center gap-1.5">
274
+ {showLicenseBadge && (
275
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
276
+ LICENSE
277
+ </span>
278
+ )}
279
+ {resource.label}
280
+ </span>
281
+ <div className="flex items-center gap-2 whitespace-nowrap">
282
+ <span className="text-gray-900 dark:text-white font-medium">
283
+ {formatCompact(resource.used)} {resource.unit !== 'dollars' ? resource.unit : ''}
284
+ </span>
285
+ {resource.costUsd > 0 && (
286
+ <span className="text-gray-400 dark:text-gray-500 text-[10px]">
287
+ (${resource.costUsd.toFixed(2)})
288
+ </span>
289
+ )}
290
+ </div>
291
+ </div>
292
+ );
293
+ }
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * SettingsCard.astro
4
+ * Reusable card component for settings sections
5
+ *
6
+ * @created 2026-02-03
7
+ * @task task-303.4
8
+ */
9
+
10
+ interface Props {
11
+ title: string;
12
+ description: string;
13
+ icon: string;
14
+ href: string;
15
+ badge?: string;
16
+ badgeColor?: 'blue' | 'green' | 'yellow' | 'red' | 'gray' | 'purple';
17
+ external?: boolean;
18
+ }
19
+
20
+ const {
21
+ title,
22
+ description,
23
+ icon,
24
+ href,
25
+ badge,
26
+ badgeColor = 'gray',
27
+ external = false,
28
+ } = Astro.props;
29
+
30
+ const badgeColors = {
31
+ blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
32
+ green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
33
+ yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
34
+ red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
35
+ gray: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
36
+ purple: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
37
+ };
38
+ ---
39
+
40
+ <a
41
+ href={href}
42
+ class="group block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md transition-all"
43
+ target={external ? '_blank' : undefined}
44
+ rel={external ? 'noopener noreferrer' : undefined}
45
+ >
46
+ <div class="flex items-start gap-4">
47
+ <!-- Icon -->
48
+ <div
49
+ class="flex-shrink-0 w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-2xl"
50
+ >
51
+ {icon}
52
+ </div>
53
+
54
+ <!-- Content -->
55
+ <div class="flex-1 min-w-0">
56
+ <div class="flex items-center gap-2 mb-1">
57
+ <h3
58
+ class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
59
+ >
60
+ {title}
61
+ </h3>
62
+ {
63
+ badge && (
64
+ <span class={`text-xs font-medium px-2 py-0.5 rounded-full ${badgeColors[badgeColor]}`}>
65
+ {badge}
66
+ </span>
67
+ )
68
+ }
69
+ {
70
+ external && (
71
+ <svg
72
+ class="w-4 h-4 text-gray-400"
73
+ fill="none"
74
+ stroke="currentColor"
75
+ viewBox="0 0 24 24"
76
+ >
77
+ <path
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ stroke-width="2"
81
+ d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
82
+ />
83
+ </svg>
84
+ )
85
+ }
86
+ </div>
87
+ <p class="text-sm text-gray-500 dark:text-gray-400">
88
+ {description}
89
+ </p>
90
+ </div>
91
+
92
+ <!-- Arrow -->
93
+ <div
94
+ class="flex-shrink-0 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors"
95
+ >
96
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
97
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
98
+ ></path>
99
+ </svg>
100
+ </div>
101
+ </div>
102
+ </a>