@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,339 @@
1
+ /**
2
+ * Feature Usage Report Component
3
+ * Shows daily feature usage metrics with project column, filtering, and pagination
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+
7
+ interface FeatureUsage {
8
+ feature_id: string;
9
+ project: string;
10
+ usage_date: string;
11
+ requests: number;
12
+ d1_reads: number;
13
+ d1_writes: number;
14
+ kv_reads: number;
15
+ kv_writes: number;
16
+ ai_neurons: number;
17
+ queue_messages: number;
18
+ }
19
+
20
+ interface FeatureSummary {
21
+ feature_id: string;
22
+ project: string;
23
+ total_requests: number;
24
+ total_d1_ops: number;
25
+ total_ai_neurons: number;
26
+ days_active: number;
27
+ }
28
+
29
+ function StatCard({ label, value, colour, subValue }: { label: string; value: number | string; colour: string; subValue?: string }) {
30
+ return (
31
+ <div className={`bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${colour}`}>
32
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">{label}</p>
33
+ <p className="text-2xl font-bold mt-1">{value}</p>
34
+ {subValue && <p className="text-xs text-gray-500 mt-0.5">{subValue}</p>}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ function formatNumber(n: number): string {
40
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
41
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
42
+ return n.toLocaleString();
43
+ }
44
+
45
+ function extractProject(featureKey: string): string {
46
+ const parts = featureKey.split(':');
47
+ return parts.length > 1 ? parts[0] : 'unknown';
48
+ }
49
+
50
+ export function FeatureUsageReport() {
51
+ const [usage, setUsage] = useState<FeatureUsage[]>([]);
52
+ const [loading, setLoading] = useState(true);
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [days, setDays] = useState(7);
55
+ const [sortBy, setSortBy] = useState<'requests' | 'd1' | 'ai' | 'project'>('requests');
56
+ const [projectFilter, setProjectFilter] = useState<string>('all');
57
+ const [page, setPage] = useState(1);
58
+ const [pageSize, setPageSize] = useState(25);
59
+
60
+ useEffect(() => {
61
+ async function fetchData() {
62
+ try {
63
+ setLoading(true);
64
+ const response = await fetch(`/api/usage/features/history?days=${days}`);
65
+ if (!response.ok) throw new Error('Failed to fetch feature usage');
66
+ const data = await response.json();
67
+
68
+ // API returns { success, features: { [featureKey]: [{ date, d1Writes, d1Reads, ... }] } }
69
+ const featuresObj = data.features || {};
70
+ const usageData: FeatureUsage[] = [];
71
+ for (const [featureKey, entries] of Object.entries(featuresObj)) {
72
+ for (const entry of entries as Array<Record<string, unknown>>) {
73
+ usageData.push({
74
+ feature_id: featureKey,
75
+ project: extractProject(featureKey),
76
+ usage_date: (entry.date as string) || '',
77
+ requests: (entry.requests as number) || 0,
78
+ d1_reads: (entry.d1Reads as number) || 0,
79
+ d1_writes: (entry.d1Writes as number) || 0,
80
+ kv_reads: (entry.kvReads as number) || 0,
81
+ kv_writes: (entry.kvWrites as number) || 0,
82
+ ai_neurons: (entry.aiNeurons as number) || 0,
83
+ queue_messages: (entry.queueMessages as number) || 0,
84
+ });
85
+ }
86
+ }
87
+
88
+ setUsage(usageData);
89
+ } catch (e) {
90
+ setError(e instanceof Error ? e.message : 'Unknown error');
91
+ } finally {
92
+ setLoading(false);
93
+ }
94
+ }
95
+ fetchData();
96
+ }, [days]);
97
+
98
+ // Reset page when filters change
99
+ useEffect(() => { setPage(1); }, [sortBy, projectFilter, pageSize, days]);
100
+
101
+ if (loading) {
102
+ return (
103
+ <div className="space-y-4">
104
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
105
+ {[...Array(4)].map((_, i) => (
106
+ <div key={i} className="bg-white dark:bg-gray-800 rounded-lg border p-4 animate-pulse">
107
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"></div>
108
+ <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-12"></div>
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (error) {
117
+ return (
118
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300">
119
+ Error loading feature usage: {error}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // Aggregate by feature
125
+ const byFeature = new Map<string, FeatureSummary>();
126
+ usage.forEach(u => {
127
+ const existing = byFeature.get(u.feature_id);
128
+ const d1Ops = u.d1_reads + u.d1_writes;
129
+ if (existing) {
130
+ existing.total_requests += u.requests;
131
+ existing.total_d1_ops += d1Ops;
132
+ existing.total_ai_neurons += u.ai_neurons;
133
+ existing.days_active += 1;
134
+ } else {
135
+ byFeature.set(u.feature_id, {
136
+ feature_id: u.feature_id,
137
+ project: u.project,
138
+ total_requests: u.requests,
139
+ total_d1_ops: d1Ops,
140
+ total_ai_neurons: u.ai_neurons,
141
+ days_active: 1,
142
+ });
143
+ }
144
+ });
145
+
146
+ // Get unique projects for filter
147
+ const allFeatures = Array.from(byFeature.values());
148
+ const projects = [...new Set(allFeatures.map(f => f.project))].sort();
149
+
150
+ // Apply project filter
151
+ const filtered = projectFilter === 'all'
152
+ ? allFeatures
153
+ : allFeatures.filter(f => f.project === projectFilter);
154
+
155
+ // Sort
156
+ const features = [...filtered].sort((a, b) => {
157
+ if (sortBy === 'project') return a.project.localeCompare(b.project);
158
+ if (sortBy === 'requests') return b.total_requests - a.total_requests;
159
+ if (sortBy === 'd1') return b.total_d1_ops - a.total_d1_ops;
160
+ return b.total_ai_neurons - a.total_ai_neurons;
161
+ });
162
+
163
+ // Pagination
164
+ const totalPages = Math.ceil(features.length / pageSize);
165
+ const paginatedFeatures = features.slice((page - 1) * pageSize, page * pageSize);
166
+
167
+ const totalRequests = allFeatures.reduce((sum, f) => sum + f.total_requests, 0);
168
+ const totalD1Ops = allFeatures.reduce((sum, f) => sum + f.total_d1_ops, 0);
169
+ const totalAiNeurons = allFeatures.reduce((sum, f) => sum + f.total_ai_neurons, 0);
170
+
171
+ return (
172
+ <div className="space-y-6">
173
+ {/* Filters */}
174
+ <div className="flex flex-wrap items-center gap-4">
175
+ <div className="flex items-center gap-2">
176
+ <label className="text-sm text-gray-600 dark:text-gray-400">Time range:</label>
177
+ <select
178
+ value={days}
179
+ onChange={(e) => setDays(Number(e.target.value))}
180
+ className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
181
+ >
182
+ <option value={7}>Last 7 days</option>
183
+ <option value={14}>Last 14 days</option>
184
+ <option value={30}>Last 30 days</option>
185
+ </select>
186
+ </div>
187
+ <div className="flex items-center gap-2">
188
+ <label className="text-sm text-gray-600 dark:text-gray-400">Project:</label>
189
+ <select
190
+ value={projectFilter}
191
+ onChange={(e) => setProjectFilter(e.target.value)}
192
+ className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
193
+ >
194
+ <option value="all">All Projects ({projects.length})</option>
195
+ {projects.map(p => (
196
+ <option key={p} value={p}>{p}</option>
197
+ ))}
198
+ </select>
199
+ </div>
200
+ <div className="flex items-center gap-2">
201
+ <label className="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
202
+ <select
203
+ value={sortBy}
204
+ onChange={(e) => setSortBy(e.target.value as 'requests' | 'd1' | 'ai' | 'project')}
205
+ className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
206
+ >
207
+ <option value="requests">Requests</option>
208
+ <option value="d1">D1 Operations</option>
209
+ <option value="ai">AI Neurons</option>
210
+ <option value="project">Project</option>
211
+ </select>
212
+ </div>
213
+ <div className="flex items-center gap-2">
214
+ <label className="text-sm text-gray-600 dark:text-gray-400">Show:</label>
215
+ <select
216
+ value={pageSize}
217
+ onChange={(e) => setPageSize(Number(e.target.value))}
218
+ className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
219
+ >
220
+ <option value={10}>10</option>
221
+ <option value={25}>25</option>
222
+ <option value={50}>50</option>
223
+ <option value={100}>100</option>
224
+ </select>
225
+ </div>
226
+ </div>
227
+
228
+ {/* Stats */}
229
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
230
+ <StatCard label="Total Requests" value={formatNumber(totalRequests)} colour="border-l-4 border-l-blue-500" />
231
+ <StatCard label="D1 Operations" value={formatNumber(totalD1Ops)} colour="border-l-4 border-l-green-500" />
232
+ <StatCard label="AI Neurons" value={formatNumber(totalAiNeurons)} colour="border-l-4 border-l-orange-500" />
233
+ <StatCard
234
+ label="Active Features"
235
+ value={features.length}
236
+ colour="border-l-4 border-l-purple-500"
237
+ subValue={projectFilter !== 'all' ? `of ${allFeatures.length} total` : `across ${projects.length} projects`}
238
+ />
239
+ </div>
240
+
241
+ {/* Feature Table */}
242
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
243
+ <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
244
+ <div>
245
+ <h3 className="font-semibold text-gray-900 dark:text-white">Feature Usage ({days} Days)</h3>
246
+ <p className="text-xs text-gray-500 mt-0.5">
247
+ {features.length} feature{features.length !== 1 ? 's' : ''}
248
+ {projectFilter !== 'all' && ` in ${projectFilter}`}
249
+ </p>
250
+ </div>
251
+ {totalPages > 1 && (
252
+ <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
253
+ <button
254
+ onClick={() => setPage(p => Math.max(1, p - 1))}
255
+ disabled={page === 1}
256
+ className="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
257
+ >
258
+ Prev
259
+ </button>
260
+ <span>{page} / {totalPages}</span>
261
+ <button
262
+ onClick={() => setPage(p => Math.min(totalPages, p + 1))}
263
+ disabled={page === totalPages}
264
+ className="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
265
+ >
266
+ Next
267
+ </button>
268
+ </div>
269
+ )}
270
+ </div>
271
+ {features.length === 0 ? (
272
+ <div className="p-8 text-center text-gray-500">No feature usage data available</div>
273
+ ) : (
274
+ <div className="overflow-x-auto">
275
+ <table className="w-full">
276
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
277
+ <tr>
278
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Project</th>
279
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Feature</th>
280
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Requests</th>
281
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">D1 Ops</th>
282
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">AI Neurons</th>
283
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Days Active</th>
284
+ </tr>
285
+ </thead>
286
+ <tbody>
287
+ {paginatedFeatures.map((f) => {
288
+ // Strip project prefix from feature_id for display
289
+ const featureDisplay = f.feature_id.startsWith(f.project + ':')
290
+ ? f.feature_id.slice(f.project.length + 1)
291
+ : f.feature_id;
292
+ return (
293
+ <tr key={f.feature_id} className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
294
+ <td className="px-4 py-3">
295
+ <span className="inline-block px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
296
+ {f.project}
297
+ </span>
298
+ </td>
299
+ <td className="px-4 py-3 font-medium text-gray-900 dark:text-white text-sm">{featureDisplay}</td>
300
+ <td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{formatNumber(f.total_requests)}</td>
301
+ <td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{formatNumber(f.total_d1_ops)}</td>
302
+ <td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{formatNumber(f.total_ai_neurons)}</td>
303
+ <td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{f.days_active}</td>
304
+ </tr>
305
+ );
306
+ })}
307
+ </tbody>
308
+ </table>
309
+ </div>
310
+ )}
311
+ {/* Bottom pagination */}
312
+ {totalPages > 1 && (
313
+ <div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
314
+ <span>
315
+ Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, features.length)} of {features.length}
316
+ </span>
317
+ <div className="flex items-center gap-2">
318
+ <button
319
+ onClick={() => setPage(p => Math.max(1, p - 1))}
320
+ disabled={page === 1}
321
+ className="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
322
+ >
323
+ Prev
324
+ </button>
325
+ <span>{page} / {totalPages}</span>
326
+ <button
327
+ onClick={() => setPage(p => Math.min(totalPages, p + 1))}
328
+ disabled={page === totalPages}
329
+ className="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
330
+ >
331
+ Next
332
+ </button>
333
+ </div>
334
+ </div>
335
+ )}
336
+ </div>
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Health Trends Report Component
3
+ * Shows project health score trends over time
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+
7
+ interface HealthTrend {
8
+ project: string;
9
+ audit_date: string;
10
+ composite_score: number;
11
+ sdk_score: number;
12
+ observability_score: number;
13
+ cost_score: number;
14
+ security_score: number;
15
+ trend: 'improving' | 'stable' | 'declining';
16
+ score_delta: number;
17
+ }
18
+
19
+ function StatCard({ label, value, colour, subValue }: { label: string; value: number | string; colour: string; subValue?: string }) {
20
+ return (
21
+ <div className={`bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${colour}`}>
22
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">{label}</p>
23
+ <p className="text-2xl font-bold mt-1">{value}</p>
24
+ {subValue && <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{subValue}</p>}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ function TrendBadge({ trend, delta }: { trend: string; delta: number }) {
30
+ const config: Record<string, { colour: string; icon: string }> = {
31
+ improving: { colour: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: '↑' },
32
+ stable: { colour: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', icon: '→' },
33
+ declining: { colour: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: '↓' },
34
+ };
35
+ const { colour, icon } = config[trend] || config.stable;
36
+ return (
37
+ <span className={`px-2 py-0.5 text-xs font-semibold rounded inline-flex items-center gap-1 ${colour}`}>
38
+ {icon} {delta > 0 ? '+' : ''}{delta}
39
+ </span>
40
+ );
41
+ }
42
+
43
+ function ScoreBar({ score, max = 5, label }: { score: number; max?: number; label: string }) {
44
+ const percentage = (score / max) * 100;
45
+ const colour = percentage >= 80 ? 'bg-green-500' : percentage >= 60 ? 'bg-yellow-500' : 'bg-red-500';
46
+ return (
47
+ <div className="flex items-center gap-2">
48
+ <span className="w-24 text-xs text-gray-600 dark:text-gray-400">{label}</span>
49
+ <div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
50
+ <div className={`h-full ${colour}`} style={{ width: `${percentage}%` }} />
51
+ </div>
52
+ <span className="w-8 text-xs text-gray-900 dark:text-white text-right">{score}/{max}</span>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export function HealthTrendsReport() {
58
+ const [trends, setTrends] = useState<HealthTrend[]>([]);
59
+ const [loading, setLoading] = useState(true);
60
+ const [error, setError] = useState<string | null>(null);
61
+ const [days, setDays] = useState(30);
62
+
63
+ useEffect(() => {
64
+ async function fetchData() {
65
+ try {
66
+ setLoading(true);
67
+ const response = await fetch(`/api/usage/health-trends?days=${days}`);
68
+ if (!response.ok) throw new Error('Failed to fetch health trends');
69
+ const data = await response.json();
70
+
71
+ // API returns { success, data: [{ project, trends: [{ date, compositeScore, rubricScores, trend, delta }] }] }
72
+ // Flatten nested project→trends structure into flat array
73
+ const apiData = data.data || [];
74
+ const flatTrends: HealthTrend[] = [];
75
+ for (const proj of apiData) {
76
+ for (const t of proj.trends || []) {
77
+ flatTrends.push({
78
+ project: proj.project,
79
+ audit_date: t.date,
80
+ composite_score: t.compositeScore,
81
+ sdk_score: t.rubricScores?.sdk ?? 0,
82
+ observability_score: t.rubricScores?.observability ?? 0,
83
+ cost_score: t.rubricScores?.cost ?? 0,
84
+ security_score: t.rubricScores?.security ?? 0,
85
+ trend: t.trend,
86
+ score_delta: t.delta,
87
+ });
88
+ }
89
+ }
90
+ setTrends(flatTrends);
91
+ } catch (e) {
92
+ setError(e instanceof Error ? e.message : 'Unknown error');
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }
97
+ fetchData();
98
+ }, [days]);
99
+
100
+ if (loading) {
101
+ return (
102
+ <div className="space-y-4">
103
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
104
+ {[...Array(4)].map((_, i) => (
105
+ <div key={i} className="bg-white dark:bg-gray-800 rounded-lg border p-4 animate-pulse">
106
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"></div>
107
+ <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-12"></div>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ if (error) {
116
+ return (
117
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300">
118
+ Error loading health trends: {error}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ const improving = trends.filter(t => t.trend === 'improving').length;
124
+ const stable = trends.filter(t => t.trend === 'stable').length;
125
+ const declining = trends.filter(t => t.trend === 'declining').length;
126
+ const avgScore = trends.length > 0
127
+ ? Math.round(trends.reduce((sum, t) => sum + t.composite_score, 0) / trends.length)
128
+ : 0;
129
+
130
+ // Group by project for latest trends
131
+ const latestByProject = new Map<string, HealthTrend>();
132
+ trends.forEach(t => {
133
+ if (!latestByProject.has(t.project) || t.audit_date > latestByProject.get(t.project)!.audit_date) {
134
+ latestByProject.set(t.project, t);
135
+ }
136
+ });
137
+ const latestTrends = Array.from(latestByProject.values()).sort((a, b) => b.composite_score - a.composite_score);
138
+
139
+ return (
140
+ <div className="space-y-6">
141
+ {/* Filter */}
142
+ <div className="flex items-center gap-4">
143
+ <label className="text-sm text-gray-600 dark:text-gray-400">Time range:</label>
144
+ <select
145
+ value={days}
146
+ onChange={(e) => setDays(Number(e.target.value))}
147
+ className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
148
+ >
149
+ <option value={7}>Last 7 days</option>
150
+ <option value={30}>Last 30 days</option>
151
+ <option value={90}>Last 90 days</option>
152
+ </select>
153
+ </div>
154
+
155
+ {/* Stats */}
156
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
157
+ <StatCard label="Improving" value={improving} colour="border-l-4 border-l-green-500" />
158
+ <StatCard label="Stable" value={stable} colour="border-l-4 border-l-gray-400" />
159
+ <StatCard label="Declining" value={declining} colour="border-l-4 border-l-red-500" />
160
+ <StatCard label="Avg Score" value={avgScore} colour="border-l-4 border-l-purple-500" />
161
+ </div>
162
+
163
+ {/* Project Health Cards */}
164
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
165
+ {latestTrends.map((t) => (
166
+ <div key={t.project} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
167
+ <div className="flex items-center justify-between mb-3">
168
+ <h3 className="font-semibold text-gray-900 dark:text-white">{t.project}</h3>
169
+ <TrendBadge trend={t.trend} delta={t.score_delta} />
170
+ </div>
171
+ <div className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
172
+ {t.composite_score}<span className="text-sm font-normal text-gray-500">/100</span>
173
+ </div>
174
+ <div className="space-y-2">
175
+ <ScoreBar score={t.sdk_score} label="SDK" />
176
+ <ScoreBar score={t.observability_score} label="Observability" />
177
+ <ScoreBar score={t.cost_score} label="Cost" />
178
+ <ScoreBar score={t.security_score} label="Security" />
179
+ </div>
180
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-3">Last audit: {t.audit_date}</p>
181
+ </div>
182
+ ))}
183
+ </div>
184
+
185
+ {latestTrends.length === 0 && (
186
+ <div className="bg-gray-50 dark:bg-gray-800 rounded-lg border p-8 text-center text-gray-500 dark:text-gray-400">
187
+ No health trend data available. Run the platform auditor to generate health scores.
188
+ </div>
189
+ )}
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * SearchResultGroup Component
3
+ *
4
+ * Renders a group header for search results by content type.
5
+ *
6
+ * @module dashboard/components/search/SearchResultGroup
7
+ * @created 2026-02-03
8
+ * @task task-303.3
9
+ */
10
+
11
+ import type { SearchContentType } from '../../lib/search/types';
12
+
13
+ /** Labels for each content type */
14
+ const CONTENT_TYPE_LABELS: Record<SearchContentType, string> = {
15
+ error: 'Errors',
16
+ pattern: 'Patterns',
17
+ setting: 'Settings',
18
+ page: 'Pages',
19
+ service: 'Services',
20
+ opportunity: 'Opportunities',
21
+ draft: 'Drafts',
22
+ project: 'Projects',
23
+ };
24
+
25
+ interface SearchResultGroupProps {
26
+ contentType: SearchContentType;
27
+ count: number;
28
+ }
29
+
30
+ export function SearchResultGroup({
31
+ contentType,
32
+ count,
33
+ }: SearchResultGroupProps): JSX.Element {
34
+ const label = CONTENT_TYPE_LABELS[contentType] || contentType;
35
+
36
+ return (
37
+ <div className="px-3 py-2 flex items-center justify-between">
38
+ <span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
39
+ {label}
40
+ </span>
41
+ <span className="text-xs text-gray-400 dark:text-gray-500">
42
+ {count} {count === 1 ? 'result' : 'results'}
43
+ </span>
44
+ </div>
45
+ );
46
+ }