@littlebearapps/platform-admin-sdk 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * NotificationItem.tsx
3
+ *
4
+ * Individual notification rendering with category-based styling,
5
+ * priority colours, and project badges.
6
+ *
7
+ * @module dashboard/components/notifications/NotificationItem
8
+ * @created 2026-02-03
9
+ * @updated 2026-02-04 - Added priority colours and project badges
10
+ * @task task-303.2, task-314
11
+ */
12
+
13
+ import type { Notification, NotificationCategory, NotificationPriority } from '../../lib/notifications/types';
14
+
15
+ export interface NotificationItemProps {
16
+ notification: Notification;
17
+ isRead: boolean;
18
+ onMarkRead?: (id: string) => void;
19
+ onDismiss?: (id: string) => void;
20
+ compact?: boolean;
21
+ }
22
+
23
+ /** Category-based icon and colour configuration */
24
+ const categoryConfig: Record<
25
+ NotificationCategory,
26
+ { icon: string; bgClass: string; iconClass: string }
27
+ > = {
28
+ error: {
29
+ icon: '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',
30
+ bgClass: 'bg-red-100 dark:bg-red-900/30',
31
+ iconClass: 'text-red-600 dark:text-red-400',
32
+ },
33
+ warning: {
34
+ icon: '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',
35
+ bgClass: 'bg-yellow-100 dark:bg-yellow-900/30',
36
+ iconClass: 'text-yellow-600 dark:text-yellow-400',
37
+ },
38
+ info: {
39
+ icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
40
+ bgClass: 'bg-blue-100 dark:bg-blue-900/30',
41
+ iconClass: 'text-blue-600 dark:text-blue-400',
42
+ },
43
+ success: {
44
+ icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
45
+ bgClass: 'bg-green-100 dark:bg-green-900/30',
46
+ iconClass: 'text-green-600 dark:text-green-400',
47
+ },
48
+ };
49
+
50
+ /** Priority-based colour configuration */
51
+ const priorityConfig: Record<
52
+ NotificationPriority,
53
+ { borderClass: string; badgeClass: string; label: string }
54
+ > = {
55
+ critical: {
56
+ borderClass: 'border-l-4 border-l-red-500',
57
+ badgeClass: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300',
58
+ label: 'Critical',
59
+ },
60
+ high: {
61
+ borderClass: 'border-l-4 border-l-orange-500',
62
+ badgeClass: 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300',
63
+ label: 'High',
64
+ },
65
+ medium: {
66
+ borderClass: 'border-l-4 border-l-yellow-500',
67
+ badgeClass: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300',
68
+ label: 'Medium',
69
+ },
70
+ low: {
71
+ borderClass: 'border-l-4 border-l-blue-400',
72
+ badgeClass: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
73
+ label: 'Low',
74
+ },
75
+ info: {
76
+ borderClass: 'border-l-4 border-l-gray-300 dark:border-l-gray-600',
77
+ badgeClass: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
78
+ label: 'Info',
79
+ },
80
+ };
81
+
82
+ /** Project display configuration */
83
+ const projectConfig: Record<string, { label: string; bgClass: string }> = {
84
+ platform: {
85
+ label: 'Platform',
86
+ bgClass: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300',
87
+ },
88
+ 'brand-copilot': {
89
+ label: 'Brand Copilot',
90
+ bgClass: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300',
91
+ },
92
+ scout: {
93
+ label: 'Scout',
94
+ bgClass: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300',
95
+ },
96
+ };
97
+
98
+ /** Source display configuration */
99
+ const sourceConfig: Record<string, { label: string; icon: string }> = {
100
+ 'error-collector': { label: 'Error', icon: 'M12 9v2m0 4h.01' },
101
+ 'pattern-discovery': { label: 'Pattern', icon: '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 2z' },
102
+ 'circuit-breaker': { label: 'Circuit', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
103
+ usage: { label: 'Usage', icon: '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' },
104
+ gatus: { label: 'Monitoring', icon: 'M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z' },
105
+ system: { label: 'System', icon: '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' },
106
+ };
107
+
108
+ /** Format relative time string */
109
+ function formatRelativeTime(timestamp: number): string {
110
+ const now = Date.now();
111
+ const seconds = Math.floor((now - timestamp * 1000) / 1000);
112
+
113
+ if (seconds < 60) return 'Just now';
114
+ if (seconds < 3600) {
115
+ const mins = Math.floor(seconds / 60);
116
+ return `${mins}m ago`;
117
+ }
118
+ if (seconds < 86400) {
119
+ const hours = Math.floor(seconds / 3600);
120
+ return `${hours}h ago`;
121
+ }
122
+ const days = Math.floor(seconds / 86400);
123
+ if (days === 1) return 'Yesterday';
124
+ if (days < 7) return `${days}d ago`;
125
+ return new Date(timestamp * 1000).toLocaleDateString('en-AU', {
126
+ day: 'numeric',
127
+ month: 'short',
128
+ });
129
+ }
130
+
131
+ export function NotificationItem({
132
+ notification,
133
+ isRead,
134
+ onMarkRead,
135
+ onDismiss,
136
+ compact = false,
137
+ }: NotificationItemProps): JSX.Element {
138
+ const catConfig = categoryConfig[notification.category] || categoryConfig.info;
139
+ const prioConfig = priorityConfig[notification.priority] || priorityConfig.info;
140
+ const projConfig = notification.project ? projectConfig[notification.project] : null;
141
+ const srcConfig = sourceConfig[notification.source] || sourceConfig.system;
142
+
143
+ const handleClick = (): void => {
144
+ if (!isRead && onMarkRead) {
145
+ onMarkRead(notification.id);
146
+ }
147
+ if (notification.action_url) {
148
+ window.location.href = notification.action_url;
149
+ }
150
+ };
151
+
152
+ const handleDismiss = (e: React.MouseEvent): void => {
153
+ e.stopPropagation();
154
+ onDismiss?.(notification.id);
155
+ };
156
+
157
+ const baseClasses = isRead
158
+ ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
159
+ : 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30';
160
+
161
+ // Show priority border for critical/high only
162
+ const priorityBorder = ['critical', 'high'].includes(notification.priority)
163
+ ? prioConfig.borderClass
164
+ : '';
165
+
166
+ return (
167
+ <div
168
+ className={`notification-item group flex items-start gap-3 px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 transition-colors ${baseClasses} ${priorityBorder}`}
169
+ onClick={handleClick}
170
+ role="button"
171
+ tabIndex={0}
172
+ onKeyDown={(e) => e.key === 'Enter' && handleClick()}
173
+ >
174
+ {/* Category icon */}
175
+ <div
176
+ className={`flex-shrink-0 w-8 h-8 rounded-full ${catConfig.bgClass} flex items-center justify-center`}
177
+ >
178
+ <svg
179
+ className={`w-4 h-4 ${catConfig.iconClass}`}
180
+ fill="none"
181
+ stroke="currentColor"
182
+ viewBox="0 0 24 24"
183
+ >
184
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={catConfig.icon} />
185
+ </svg>
186
+ </div>
187
+
188
+ {/* Content */}
189
+ <div className="flex-1 min-w-0">
190
+ {/* Title row with priority badge */}
191
+ <div className="flex items-center gap-2 flex-wrap">
192
+ <p
193
+ className={`text-sm ${isRead ? 'text-gray-700 dark:text-gray-300' : 'font-medium text-gray-900 dark:text-white'}`}
194
+ >
195
+ {notification.title}
196
+ </p>
197
+ {/* Priority badge for critical/high */}
198
+ {['critical', 'high'].includes(notification.priority) && (
199
+ <span className={`px-1.5 py-0.5 text-xs font-medium rounded ${prioConfig.badgeClass}`}>
200
+ {prioConfig.label}
201
+ </span>
202
+ )}
203
+ </div>
204
+
205
+ {/* Description */}
206
+ {notification.description && !compact && (
207
+ <p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
208
+ {notification.description}
209
+ </p>
210
+ )}
211
+
212
+ {/* Metadata row: time, project, source */}
213
+ <div className="flex items-center gap-2 mt-1 flex-wrap">
214
+ <span className="text-xs text-gray-400 dark:text-gray-500">
215
+ {formatRelativeTime(notification.created_at)}
216
+ </span>
217
+
218
+ {/* Project badge */}
219
+ {projConfig && (
220
+ <span className={`px-1.5 py-0.5 text-xs rounded ${projConfig.bgClass}`}>
221
+ {projConfig.label}
222
+ </span>
223
+ )}
224
+
225
+ {/* Source indicator */}
226
+ <span className="text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1">
227
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
228
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={srcConfig.icon} />
229
+ </svg>
230
+ {srcConfig.label}
231
+ </span>
232
+ </div>
233
+ </div>
234
+
235
+ {/* Unread indicator */}
236
+ {!isRead && <div className="flex-shrink-0 w-2 h-2 mt-2 bg-blue-500 rounded-full" />}
237
+
238
+ {/* Dismiss button (optional) */}
239
+ {onDismiss && (
240
+ <button
241
+ onClick={handleDismiss}
242
+ className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
243
+ aria-label="Dismiss notification"
244
+ >
245
+ <svg
246
+ className="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
247
+ fill="none"
248
+ stroke="currentColor"
249
+ viewBox="0 0 24 24"
250
+ >
251
+ <path
252
+ strokeLinecap="round"
253
+ strokeLinejoin="round"
254
+ strokeWidth={2}
255
+ d="M6 18L18 6M6 6l12 12"
256
+ />
257
+ </svg>
258
+ </button>
259
+ )}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ export default NotificationItem;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * PatternInfoButton Component
3
+ * Info icon button that toggles a panel explaining how pattern discovery works
4
+ */
5
+ import { useState, useRef, useEffect } from 'react';
6
+
7
+ export function PatternInfoButton() {
8
+ const [open, setOpen] = useState(false);
9
+ const panelRef = useRef<HTMLDivElement>(null);
10
+
11
+ useEffect(() => {
12
+ if (!open) return;
13
+
14
+ function handleClickOutside(event: MouseEvent) {
15
+ if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
16
+ setOpen(false);
17
+ }
18
+ }
19
+
20
+ document.addEventListener('mousedown', handleClickOutside);
21
+ return () => document.removeEventListener('mousedown', handleClickOutside);
22
+ }, [open]);
23
+
24
+ return (
25
+ <div className="relative" ref={panelRef}>
26
+ <button
27
+ onClick={() => setOpen(!open)}
28
+ className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 text-blue-600 dark:text-blue-400 transition-colors"
29
+ title="How Pattern Discovery Works"
30
+ aria-label="How Pattern Discovery Works"
31
+ >
32
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
33
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
34
+ </svg>
35
+ </button>
36
+
37
+ {open && (
38
+ <div className="absolute right-0 top-12 z-50 w-96 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 shadow-lg">
39
+ <h3 className="font-semibold text-blue-800 dark:text-blue-300 mb-2">
40
+ How Pattern Discovery Works
41
+ </h3>
42
+ <ol className="text-sm text-blue-700 dark:text-blue-400 space-y-1.5 list-decimal list-inside">
43
+ <li>Daily cron job (2am UTC) analyses unclassified errors in D1</li>
44
+ <li>Similar errors are clustered and sent to AI (DeepSeek) for pattern suggestions</li>
45
+ <li>AI suggests patterns using a constrained DSL (contains, startsWith, statusCode, regex)</li>
46
+ <li>Human reviews and approves/rejects suggestions with backtest validation</li>
47
+ <li>Approved patterns are cached in KV and loaded by error-collector at runtime</li>
48
+ <li>System patterns are always active (built into error-collector code)</li>
49
+ </ol>
50
+ <button
51
+ onClick={() => setOpen(false)}
52
+ className="mt-3 text-xs text-blue-600 dark:text-blue-400 hover:underline"
53
+ >
54
+ Close
55
+ </button>
56
+ </div>
57
+ )}
58
+ </div>
59
+ );
60
+ }
@@ -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
+ }