@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.
- package/README.md +2 -5
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +121 -3
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
- package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
- package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
- package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
- package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
- package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
- package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
- package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
- package/templates/full/dashboard/src/pages/feedback.astro +365 -0
- package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
- package/templates/full/dashboard/src/pages/map.astro +561 -0
- package/templates/full/dashboard/src/pages/revenue.astro +72 -0
- package/templates/full/dashboard/src/pages/tests.astro +431 -0
- package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
- package/templates/full/scripts/ops/verify-account-total.ts +256 -0
- package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
- package/templates/full/tests/integration/r2-archive.test.ts +108 -0
- package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
- package/templates/shared/.github/workflows/validate-controls.yml +27 -0
- package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
- package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
- package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
- package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
- package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
- package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
- package/templates/shared/dashboard/src/components/Toast.astro +170 -0
- package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
- package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
- package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
- package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
- package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
- package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
- package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
- package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
- package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
- package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
- package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
- package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
- package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
- package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
- package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
- package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
- package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
- package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
- package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
- package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
- package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
- package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
- package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
- package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
- package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
- package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
- package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
- package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
- package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
- package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
- package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
- package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
- package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
- package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
- package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
- package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
- package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
- package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
- package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
- package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
- package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
- package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
- package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
- package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
- package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
- package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
- package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
- package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
- package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
- package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
- package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
- package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
- package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
- package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
- package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
- package/templates/standard/tests/integration/connectors.test.ts +241 -0
- package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
- 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>
|