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