@littlebearapps/platform-admin-sdk 2.1.0 → 2.3.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 (122) hide show
  1. package/README.md +2 -5
  2. package/dist/check-upgrade.d.ts +29 -0
  3. package/dist/check-upgrade.js +97 -0
  4. package/dist/index.js +59 -4
  5. package/dist/manifest.d.ts +2 -0
  6. package/dist/scaffold.js +5 -1
  7. package/dist/templates.d.ts +6 -1
  8. package/dist/templates.js +141 -3
  9. package/dist/upgrade.d.ts +1 -0
  10. package/dist/upgrade.js +21 -2
  11. package/package.json +1 -1
  12. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  13. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  14. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  15. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  16. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  17. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  18. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  19. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  20. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  21. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  22. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  23. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  24. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  25. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  26. package/templates/full/dashboard/src/pages/map.astro +561 -0
  27. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  28. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  29. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  30. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  31. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  32. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  33. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  34. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  35. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  36. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  37. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  38. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  39. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  40. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  41. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  42. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  43. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  44. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  45. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  46. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  47. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  48. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  49. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  50. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  51. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  52. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  53. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  54. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  55. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  56. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  57. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  58. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  59. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  60. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  61. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  62. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  63. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  64. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  65. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  66. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  67. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  68. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  69. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  70. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  71. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  72. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  73. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  74. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  75. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  76. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  77. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  78. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  79. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  80. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  81. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  82. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  83. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  84. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  85. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  86. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  87. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  88. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  89. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  90. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  91. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  92. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  93. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  94. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  95. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  96. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  97. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  98. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  99. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  100. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  101. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  102. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  103. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  104. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  105. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  107. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  108. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  109. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  110. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  111. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  112. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  113. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  114. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  115. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  116. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  117. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  118. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  119. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  120. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  121. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  122. package/templates/standard/tests/integration/ingestion.test.ts +211 -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,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
+ }
@@ -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,49 @@
1
+ /**
2
+ * POST /api/patterns/:id/approve - Proxy to pattern-discovery /suggestions/:id?action=approve
3
+ * Approves a pending pattern suggestion
4
+ */
5
+ import type { APIRoute } from 'astro';
6
+
7
+ interface Env {
8
+ PATTERN_DISCOVERY_API?: Fetcher;
9
+ }
10
+
11
+ export const POST: APIRoute = async ({ locals, params, url }) => {
12
+ const api = (locals.runtime?.env as Env)?.PATTERN_DISCOVERY_API;
13
+
14
+ if (!api) {
15
+ return new Response(JSON.stringify({ error: 'Pattern Discovery API not configured' }), {
16
+ status: 503,
17
+ headers: { 'Content-Type': 'application/json' },
18
+ });
19
+ }
20
+
21
+ const { id } = params;
22
+ if (!id) {
23
+ return new Response(JSON.stringify({ error: 'Missing suggestion ID' }), {
24
+ status: 400,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ });
27
+ }
28
+
29
+ try {
30
+ const by = url.searchParams.get('by') || 'dashboard';
31
+
32
+ const response = await api.fetch(
33
+ `https://pattern-discovery.littlebearapps.workers.dev/suggestions/${id}?action=approve&by=${by}`,
34
+ { method: 'POST' }
35
+ );
36
+
37
+ const data = await response.json();
38
+ return new Response(JSON.stringify(data), {
39
+ status: response.status,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ } catch (error) {
43
+ console.error('Error approving suggestion:', error);
44
+ return new Response(JSON.stringify({ error: 'Failed to approve suggestion' }), {
45
+ status: 500,
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+ }
49
+ };