@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.
- package/README.md +4 -7
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +206 -4
- 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/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -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/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -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/reports/ReportInfoButton.tsx +98 -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/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -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/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -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-api.test.ts +909 -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/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -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/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -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
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- 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
|
+
}
|