@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,212 @@
1
+ /**
2
+ * SearchResultItem Component
3
+ *
4
+ * Renders a single search result with icon, title, snippet, and content type indicator.
5
+ *
6
+ * @module dashboard/components/search/SearchResultItem
7
+ * @created 2026-02-03
8
+ * @task task-303.3
9
+ */
10
+
11
+ import type { SearchResult, SearchContentType } from '../../lib/search/types';
12
+
13
+ /** Icons for each content type */
14
+ const CONTENT_TYPE_ICONS: Record<SearchContentType, JSX.Element> = {
15
+ error: (
16
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
18
+ </svg>
19
+ ),
20
+ pattern: (
21
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
22
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
23
+ </svg>
24
+ ),
25
+ setting: (
26
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
28
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
29
+ </svg>
30
+ ),
31
+ page: (
32
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
33
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
34
+ </svg>
35
+ ),
36
+ service: (
37
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
39
+ </svg>
40
+ ),
41
+ opportunity: (
42
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
43
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
44
+ </svg>
45
+ ),
46
+ draft: (
47
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
48
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
49
+ </svg>
50
+ ),
51
+ project: (
52
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
54
+ </svg>
55
+ ),
56
+ };
57
+
58
+ /** Colour classes for each content type */
59
+ const CONTENT_TYPE_COLORS: Record<SearchContentType, string> = {
60
+ error: 'text-red-500 dark:text-red-400 bg-red-100 dark:bg-red-900/30',
61
+ pattern: 'text-purple-500 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30',
62
+ setting: 'text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700',
63
+ page: 'text-blue-500 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30',
64
+ service: 'text-green-500 dark:text-green-400 bg-green-100 dark:bg-green-900/30',
65
+ opportunity: 'text-orange-500 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30',
66
+ draft: 'text-cyan-500 dark:text-cyan-400 bg-cyan-100 dark:bg-cyan-900/30',
67
+ project: 'text-indigo-500 dark:text-indigo-400 bg-indigo-100 dark:bg-indigo-900/30',
68
+ };
69
+
70
+ /** Labels for each content type */
71
+ const CONTENT_TYPE_LABELS: Record<SearchContentType, string> = {
72
+ error: 'Error',
73
+ pattern: 'Pattern',
74
+ setting: 'Setting',
75
+ page: 'Page',
76
+ service: 'Service',
77
+ opportunity: 'Opportunity',
78
+ draft: 'Draft',
79
+ project: 'Project',
80
+ };
81
+
82
+ interface SearchResultItemProps {
83
+ result: SearchResult;
84
+ isSelected: boolean;
85
+ query: string;
86
+ onClick: () => void;
87
+ }
88
+
89
+ export function SearchResultItem({
90
+ result,
91
+ isSelected,
92
+ query,
93
+ onClick,
94
+ }: SearchResultItemProps): JSX.Element {
95
+ const icon = CONTENT_TYPE_ICONS[result.content_type] || CONTENT_TYPE_ICONS.page;
96
+ const colorClass = CONTENT_TYPE_COLORS[result.content_type] || CONTENT_TYPE_COLORS.page;
97
+ const label = CONTENT_TYPE_LABELS[result.content_type] || result.content_type;
98
+
99
+ // Highlight matching terms in the snippet - sanitized by only allowing alphanumeric matches wrapped in mark tags
100
+ const snippetText = result.snippet || result.content || '';
101
+ const highlightedParts = getHighlightedParts(snippetText, query);
102
+
103
+ return (
104
+ <a
105
+ href={result.url}
106
+ onClick={(e) => {
107
+ e.preventDefault();
108
+ onClick();
109
+ }}
110
+ className={`flex items-start gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
111
+ isSelected
112
+ ? 'bg-blue-50 dark:bg-blue-900/30'
113
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700'
114
+ }`}
115
+ >
116
+ {/* Icon */}
117
+ <span
118
+ className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${colorClass}`}
119
+ >
120
+ {icon}
121
+ </span>
122
+
123
+ {/* Content */}
124
+ <div className="flex-1 min-w-0">
125
+ <div className="flex items-center gap-2">
126
+ <span className="font-medium text-gray-900 dark:text-white truncate">
127
+ {result.title}
128
+ </span>
129
+ <span
130
+ className={`text-xs px-1.5 py-0.5 rounded ${colorClass}`}
131
+ >
132
+ {label}
133
+ </span>
134
+ </div>
135
+ {snippetText && (
136
+ <p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">
137
+ {highlightedParts.map((part, i) =>
138
+ part.isMatch ? (
139
+ <mark key={i} className="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">
140
+ {part.text}
141
+ </mark>
142
+ ) : (
143
+ <span key={i}>{part.text}</span>
144
+ )
145
+ )}
146
+ </p>
147
+ )}
148
+ {result.project && (
149
+ <span className="text-xs text-gray-400 dark:text-gray-500">
150
+ {result.project}
151
+ </span>
152
+ )}
153
+ </div>
154
+
155
+ {/* Arrow indicator when selected */}
156
+ {isSelected && (
157
+ <kbd className="flex-shrink-0 text-xs text-gray-400 border border-gray-200 dark:border-gray-600 rounded px-1.5 py-0.5">
158
+
159
+ </kbd>
160
+ )}
161
+ </a>
162
+ );
163
+ }
164
+
165
+ interface HighlightPart {
166
+ text: string;
167
+ isMatch: boolean;
168
+ }
169
+
170
+ /**
171
+ * Get highlighted parts of text matching search terms
172
+ * Returns array of parts with isMatch flag for safe rendering
173
+ */
174
+ function getHighlightedParts(text: string, query: string): HighlightPart[] {
175
+ if (!text || !query.trim()) {
176
+ return [{ text, isMatch: false }];
177
+ }
178
+
179
+ const terms = query
180
+ .toLowerCase()
181
+ .split(/\s+/)
182
+ .filter((t) => t.length > 2);
183
+
184
+ if (terms.length === 0) {
185
+ return [{ text, isMatch: false }];
186
+ }
187
+
188
+ // Escape special regex characters
189
+ const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
190
+ const pattern = new RegExp(`(${escaped.join('|')})`, 'gi');
191
+
192
+ const parts: HighlightPart[] = [];
193
+ let lastIndex = 0;
194
+ let match: RegExpExecArray | null;
195
+
196
+ while ((match = pattern.exec(text)) !== null) {
197
+ // Add non-matching text before this match
198
+ if (match.index > lastIndex) {
199
+ parts.push({ text: text.slice(lastIndex, match.index), isMatch: false });
200
+ }
201
+ // Add the matching text
202
+ parts.push({ text: match[0], isMatch: true });
203
+ lastIndex = pattern.lastIndex;
204
+ }
205
+
206
+ // Add remaining text after last match
207
+ if (lastIndex < text.length) {
208
+ parts.push({ text: text.slice(lastIndex), isMatch: false });
209
+ }
210
+
211
+ return parts.length > 0 ? parts : [{ text, isMatch: false }];
212
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * AI Model Breakdown Component
3
+ *
4
+ * Displays per-model usage from Workers AI and AI Gateway.
5
+ * Shows costs, requests, and token counts by model.
6
+ */
7
+ import { useState, useEffect } from 'react';
8
+
9
+ // =============================================================================
10
+ // TYPES
11
+ // =============================================================================
12
+
13
+ interface WorkersAIModel {
14
+ model: string;
15
+ project: string;
16
+ requests: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ costUsd: number;
20
+ }
21
+
22
+ interface AIGatewayModel {
23
+ provider: string;
24
+ model: string;
25
+ gatewayId: string;
26
+ requests: number;
27
+ cachedRequests: number;
28
+ tokensIn: number;
29
+ tokensOut: number;
30
+ costUsd: number;
31
+ }
32
+
33
+ interface ApiResponse {
34
+ success: boolean;
35
+ workersAI?: {
36
+ models: WorkersAIModel[];
37
+ totalCostUsd: number;
38
+ totalRequests: number;
39
+ };
40
+ aiGateway?: {
41
+ models: AIGatewayModel[];
42
+ totalCostUsd: number;
43
+ totalRequests: number;
44
+ byProvider: Record<string, { requests: number; costUsd: number }>;
45
+ };
46
+ error?: string;
47
+ }
48
+
49
+ interface Props {
50
+ period: string;
51
+ }
52
+
53
+ // =============================================================================
54
+ // CONSTANTS
55
+ // =============================================================================
56
+
57
+ const PROVIDER_COLORS: Record<string, string> = {
58
+ openai: 'bg-green-500',
59
+ anthropic: 'bg-orange-500',
60
+ 'google-ai-studio': 'bg-blue-500',
61
+ 'workers-ai': 'bg-amber-500',
62
+ deepseek: 'bg-cyan-500',
63
+ minimax: 'bg-indigo-500',
64
+ };
65
+
66
+ const PROVIDER_LABELS: Record<string, string> = {
67
+ openai: 'OpenAI',
68
+ anthropic: 'Anthropic',
69
+ 'google-ai-studio': 'Google AI',
70
+ 'workers-ai': 'Workers AI',
71
+ deepseek: 'DeepSeek',
72
+ minimax: 'MiniMax',
73
+ };
74
+
75
+ // =============================================================================
76
+ // HELPERS
77
+ // =============================================================================
78
+
79
+ function formatNumber(n: number): string {
80
+ if (n >= 1_000_000) {
81
+ return `${(n / 1_000_000).toFixed(1)}M`;
82
+ }
83
+ if (n >= 1_000) {
84
+ return `${(n / 1_000).toFixed(1)}K`;
85
+ }
86
+ return n.toLocaleString();
87
+ }
88
+
89
+ function formatModelName(model: string): string {
90
+ // Shorten common model prefixes
91
+ return model
92
+ .replace('@cf/meta/', '')
93
+ .replace('@cf/mistral/', '')
94
+ .replace('@cf/', '')
95
+ .replace('gpt-', 'GPT-')
96
+ .replace('claude-', 'Claude ')
97
+ .replace('gemini-', 'Gemini ');
98
+ }
99
+
100
+ // =============================================================================
101
+ // COMPONENTS
102
+ // =============================================================================
103
+
104
+ function LoadingState() {
105
+ return (
106
+ <div className="animate-pulse space-y-3">
107
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
108
+ <div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
109
+ <div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function EmptyState() {
115
+ return (
116
+ <div className="text-center py-8 text-gray-500 dark:text-gray-400">
117
+ <div className="text-3xl mb-2">🤖</div>
118
+ <p className="text-sm">No AI model usage data available</p>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function ProviderBar({
124
+ byProvider,
125
+ totalCost,
126
+ }: {
127
+ byProvider: Record<string, { requests: number; costUsd: number }>;
128
+ totalCost: number;
129
+ }) {
130
+ const providers = Object.entries(byProvider).sort((a, b) => b[1].costUsd - a[1].costUsd);
131
+
132
+ if (providers.length === 0 || totalCost === 0) return null;
133
+
134
+ return (
135
+ <div className="mb-4">
136
+ <div className="flex h-3 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
137
+ {providers.map(([provider, data]) => {
138
+ const width = (data.costUsd / totalCost) * 100;
139
+ if (width < 1) return null;
140
+ return (
141
+ <div
142
+ key={provider}
143
+ className={`${PROVIDER_COLORS[provider] || 'bg-gray-400'} transition-all`}
144
+ style={{ width: `${width}%` }}
145
+ title={`${PROVIDER_LABELS[provider] || provider}: $${data.costUsd.toFixed(2)}`}
146
+ />
147
+ );
148
+ })}
149
+ </div>
150
+ <div className="flex flex-wrap gap-3 mt-2">
151
+ {providers.map(([provider, data]) => (
152
+ <div key={provider} className="flex items-center gap-1.5 text-xs">
153
+ <span className={`w-2 h-2 rounded-full ${PROVIDER_COLORS[provider] || 'bg-gray-400'}`} />
154
+ <span className="text-gray-600 dark:text-gray-400">
155
+ {PROVIDER_LABELS[provider] || provider}
156
+ </span>
157
+ <span className="text-gray-900 dark:text-white font-medium">
158
+ ${data.costUsd.toFixed(2)}
159
+ </span>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function ModelTable({ models, type }: { models: (WorkersAIModel | AIGatewayModel)[]; type: 'workersai' | 'gateway' }) {
168
+ const [expanded, setExpanded] = useState(false);
169
+ const displayModels = expanded ? models : models.slice(0, 5);
170
+ const hasMore = models.length > 5;
171
+
172
+ return (
173
+ <div className="overflow-x-auto">
174
+ <table className="w-full text-sm">
175
+ <thead>
176
+ <tr className="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
177
+ <th className="pb-2 font-medium">Model</th>
178
+ {type === 'gateway' && <th className="pb-2 font-medium">Provider</th>}
179
+ <th className="pb-2 font-medium text-right">Requests</th>
180
+ <th className="pb-2 font-medium text-right">Tokens</th>
181
+ <th className="pb-2 font-medium text-right">Cost</th>
182
+ </tr>
183
+ </thead>
184
+ <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
185
+ {displayModels.map((m, idx) => {
186
+ const isGateway = 'provider' in m;
187
+ const tokens = isGateway
188
+ ? (m as AIGatewayModel).tokensIn + (m as AIGatewayModel).tokensOut
189
+ : (m as WorkersAIModel).inputTokens + (m as WorkersAIModel).outputTokens;
190
+
191
+ return (
192
+ <tr key={idx} className="text-gray-900 dark:text-white">
193
+ <td className="py-2">
194
+ <code className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
195
+ {formatModelName(m.model)}
196
+ </code>
197
+ </td>
198
+ {type === 'gateway' && (
199
+ <td className="py-2 text-gray-600 dark:text-gray-400">
200
+ {PROVIDER_LABELS[(m as AIGatewayModel).provider] || (m as AIGatewayModel).provider}
201
+ </td>
202
+ )}
203
+ <td className="py-2 text-right font-mono">{formatNumber(m.requests)}</td>
204
+ <td className="py-2 text-right font-mono text-gray-600 dark:text-gray-400">
205
+ {formatNumber(tokens)}
206
+ </td>
207
+ <td className="py-2 text-right font-medium">${m.costUsd.toFixed(2)}</td>
208
+ </tr>
209
+ );
210
+ })}
211
+ </tbody>
212
+ </table>
213
+ {hasMore && (
214
+ <button
215
+ onClick={() => setExpanded(!expanded)}
216
+ className="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
217
+ >
218
+ {expanded ? 'Show less' : `Show ${models.length - 5} more models`}
219
+ </button>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ // =============================================================================
226
+ // MAIN COMPONENT
227
+ // =============================================================================
228
+
229
+ export default function AIModelBreakdown({ period }: Props) {
230
+ const [loading, setLoading] = useState(true);
231
+ const [error, setError] = useState<string | null>(null);
232
+ const [data, setData] = useState<ApiResponse | null>(null);
233
+ const [activeTab, setActiveTab] = useState<'gateway' | 'workersai'>('gateway');
234
+
235
+ useEffect(() => {
236
+ async function fetchData() {
237
+ setLoading(true);
238
+ setError(null);
239
+
240
+ try {
241
+ const response = await fetch(`/api/usage/ai-models?period=${period}`);
242
+ const result: ApiResponse = await response.json();
243
+
244
+ if (!result.success) {
245
+ throw new Error(result.error || 'Failed to load data');
246
+ }
247
+
248
+ setData(result);
249
+ } catch (err) {
250
+ setError(err instanceof Error ? err.message : 'Unknown error');
251
+ } finally {
252
+ setLoading(false);
253
+ }
254
+ }
255
+
256
+ fetchData();
257
+ }, [period]);
258
+
259
+ if (loading) {
260
+ return (
261
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
262
+ <h3 className="font-medium text-gray-900 dark:text-white mb-4">AI Model Usage</h3>
263
+ <LoadingState />
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (error) {
269
+ return (
270
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
271
+ <h3 className="font-medium text-gray-900 dark:text-white mb-2">AI Model Usage</h3>
272
+ <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const hasGatewayData = (data?.aiGateway?.models?.length ?? 0) > 0;
278
+ const hasWorkersAIData = (data?.workersAI?.models?.length ?? 0) > 0;
279
+
280
+ if (!hasGatewayData && !hasWorkersAIData) {
281
+ return (
282
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
283
+ <h3 className="font-medium text-gray-900 dark:text-white mb-2">AI Model Usage</h3>
284
+ <EmptyState />
285
+ </div>
286
+ );
287
+ }
288
+
289
+ return (
290
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
291
+ {/* Header with tabs */}
292
+ <div className="border-b border-gray-200 dark:border-gray-700">
293
+ <div className="flex items-center justify-between p-4 pb-0">
294
+ <h3 className="font-medium text-gray-900 dark:text-white">AI Model Usage</h3>
295
+ <div className="text-sm">
296
+ <span className="text-gray-500 dark:text-gray-400">Total: </span>
297
+ <span className="font-semibold text-gray-900 dark:text-white">
298
+ ${((data?.aiGateway?.totalCostUsd ?? 0) + (data?.workersAI?.totalCostUsd ?? 0)).toFixed(2)}
299
+ </span>
300
+ </div>
301
+ </div>
302
+
303
+ {/* Tabs */}
304
+ <div className="flex px-4 mt-3">
305
+ <button
306
+ onClick={() => setActiveTab('gateway')}
307
+ className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
308
+ activeTab === 'gateway'
309
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
310
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
311
+ }`}
312
+ >
313
+ AI Gateway
314
+ {hasGatewayData && (
315
+ <span className="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
316
+ ${data?.aiGateway?.totalCostUsd?.toFixed(2)}
317
+ </span>
318
+ )}
319
+ </button>
320
+ <button
321
+ onClick={() => setActiveTab('workersai')}
322
+ className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
323
+ activeTab === 'workersai'
324
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
325
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
326
+ }`}
327
+ >
328
+ Workers AI
329
+ {hasWorkersAIData && (
330
+ <span className="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
331
+ ${data?.workersAI?.totalCostUsd?.toFixed(2)}
332
+ </span>
333
+ )}
334
+ </button>
335
+ </div>
336
+ </div>
337
+
338
+ {/* Content */}
339
+ <div className="p-4">
340
+ {activeTab === 'gateway' && data?.aiGateway && (
341
+ <>
342
+ <ProviderBar
343
+ byProvider={data.aiGateway.byProvider}
344
+ totalCost={data.aiGateway.totalCostUsd}
345
+ />
346
+ {hasGatewayData ? (
347
+ <ModelTable models={data.aiGateway.models} type="gateway" />
348
+ ) : (
349
+ <EmptyState />
350
+ )}
351
+ </>
352
+ )}
353
+
354
+ {activeTab === 'workersai' && data?.workersAI && (
355
+ hasWorkersAIData ? (
356
+ <ModelTable models={data.workersAI.models} type="workersai" />
357
+ ) : (
358
+ <EmptyState />
359
+ )
360
+ )}
361
+ </div>
362
+ </div>
363
+ );
364
+ }