@littlebearapps/platform-admin-sdk 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +2 -5
  2. package/dist/check-upgrade.d.ts +29 -0
  3. package/dist/check-upgrade.js +97 -0
  4. package/dist/index.js +59 -4
  5. package/dist/manifest.d.ts +2 -0
  6. package/dist/scaffold.js +5 -1
  7. package/dist/templates.d.ts +6 -1
  8. package/dist/templates.js +141 -3
  9. package/dist/upgrade.d.ts +1 -0
  10. package/dist/upgrade.js +21 -2
  11. package/package.json +1 -1
  12. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  13. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  14. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  15. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  16. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  17. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  18. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  19. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  20. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  21. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  22. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  23. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  24. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  25. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  26. package/templates/full/dashboard/src/pages/map.astro +561 -0
  27. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  28. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  29. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  30. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  31. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  32. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  33. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  34. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  35. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  36. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  37. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  38. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  39. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  40. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  41. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  42. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  43. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  44. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  45. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  46. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  47. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  48. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  49. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  50. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  51. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  52. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  53. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  54. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  55. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  56. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  57. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  58. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  59. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  60. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  61. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  62. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  63. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  64. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  65. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  66. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  67. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  68. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  69. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  70. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  71. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  72. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  73. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  74. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  75. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  76. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  77. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  78. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  79. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  80. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  81. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  82. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  83. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  84. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  85. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  86. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  87. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  88. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  89. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  90. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  91. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  92. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  93. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  94. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  95. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  96. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  97. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  98. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  99. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  100. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  101. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  102. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  103. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  104. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  105. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  107. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  108. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  109. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  110. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  111. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  112. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  113. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  114. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  115. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  116. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  117. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  118. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  119. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  120. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  121. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  122. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
package/dist/upgrade.js CHANGED
@@ -14,7 +14,7 @@ import { resolve, dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import Handlebars from 'handlebars';
16
16
  import pc from 'picocolors';
17
- import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame } from './templates.js';
17
+ import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame, detectCollisions } from './templates.js';
18
18
  import { readManifest, writeManifest, buildManifest, hashContent, MANIFEST_FILENAME, } from './manifest.js';
19
19
  import { findHighestMigration, getMigrationNumber, planMigrations } from './migrations.js';
20
20
  const __filename = fileURLToPath(import.meta.url);
@@ -46,7 +46,11 @@ export async function upgrade(projectDir, options = {}) {
46
46
  }
47
47
  if (manifest.sdkVersion === SDK_VERSION && manifest.tier === targetTier) {
48
48
  console.log(pc.green(` Already up to date (SDK ${SDK_VERSION}, tier ${targetTier}).`));
49
- return { created: [], updated: [], skipped: [], removed: [], migrations: [] };
49
+ return { created: [], updated: [], skipped: [], removed: [], migrations: [], excluded: [] };
50
+ }
51
+ const collisions = detectCollisions(targetTier);
52
+ if (collisions.length > 0) {
53
+ throw new Error(`Template naming collisions detected:\n${collisions.map((c) => ` - ${c}`).join('\n')}`);
50
54
  }
51
55
  const templatesDir = getTemplatesDir();
52
56
  const files = getFilesForTier(targetTier);
@@ -59,12 +63,14 @@ export async function upgrade(projectDir, options = {}) {
59
63
  defaultAssignee: manifest.context.defaultAssignee,
60
64
  sdkVersion: SDK_VERSION,
61
65
  };
66
+ const excludeSet = new Set(manifest.excludeFromUpgrade ?? []);
62
67
  const result = {
63
68
  created: [],
64
69
  updated: [],
65
70
  skipped: [],
66
71
  removed: [],
67
72
  migrations: [],
73
+ excluded: [],
68
74
  };
69
75
  // Separate regular files from migrations
70
76
  const regularFiles = files.filter((f) => !isMigrationFile(f));
@@ -75,6 +81,16 @@ export async function upgrade(projectDir, options = {}) {
75
81
  const srcPath = join(templatesDir, file.src);
76
82
  const destRelative = renderString(file.dest, context);
77
83
  const destPath = join(projectDir, destRelative);
84
+ if (excludeSet.has(destRelative)) {
85
+ console.log(` ${pc.dim('exclude')} ${destRelative} ${pc.dim('(in excludeFromUpgrade)')}`);
86
+ result.excluded.push(destRelative);
87
+ // Preserve existing hash in new manifest if file is on disk
88
+ if (existsSync(destPath)) {
89
+ const diskContent = readFileSync(destPath, 'utf-8');
90
+ newFileHashes[destRelative] = hashContent(diskContent);
91
+ }
92
+ continue;
93
+ }
78
94
  if (!existsSync(srcPath))
79
95
  continue;
80
96
  const raw = readFileSync(srcPath, 'utf-8');
@@ -174,6 +190,9 @@ export async function upgrade(projectDir, options = {}) {
174
190
  // --- Write updated manifest ---
175
191
  if (!options.dryRun) {
176
192
  const newManifest = buildManifest(SDK_VERSION, targetTier, manifest.context, newFileHashes, highestScaffoldMig);
193
+ if (manifest.excludeFromUpgrade && manifest.excludeFromUpgrade.length > 0) {
194
+ newManifest.excludeFromUpgrade = manifest.excludeFromUpgrade;
195
+ }
177
196
  writeManifest(projectDir, newManifest);
178
197
  }
179
198
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/platform-admin-sdk",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Platform Admin SDK — scaffold backend infrastructure with workers, circuit breakers, and cost protection for Cloudflare",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * NotificationDropdown.tsx
3
+ *
4
+ * Dropdown panel showing recent notifications with mark-as-read actions.
5
+ *
6
+ * @module dashboard/components/notifications/NotificationDropdown
7
+ * @created 2026-02-03
8
+ * @task task-303.2
9
+ */
10
+
11
+ import { NotificationItem } from './NotificationItem';
12
+ import type { Notification } from '../../lib/notifications/types';
13
+
14
+ export interface NotificationDropdownProps {
15
+ notifications: Notification[];
16
+ readIds: Set<string>;
17
+ isOpen: boolean;
18
+ onMarkRead: (id: string) => void;
19
+ onMarkAllRead: () => void;
20
+ onClose: () => void;
21
+ loading?: boolean;
22
+ }
23
+
24
+ export function NotificationDropdown({
25
+ notifications,
26
+ readIds,
27
+ isOpen,
28
+ onMarkRead,
29
+ onMarkAllRead,
30
+ _onClose,
31
+ loading = false,
32
+ }: NotificationDropdownProps): JSX.Element | null {
33
+ if (!isOpen) return null;
34
+
35
+ const hasUnread = notifications.some((n) => !readIds.has(n.id));
36
+
37
+ return (
38
+ <div
39
+ className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-60 overflow-hidden animate-slide-down"
40
+ role="menu"
41
+ aria-orientation="vertical"
42
+ aria-label="Notifications"
43
+ >
44
+ {/* Header */}
45
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
46
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white">Notifications</h3>
47
+ {hasUnread ? (
48
+ <button
49
+ onClick={onMarkAllRead}
50
+ className="text-xs text-blue-600 dark:text-blue-400 hover:underline focus:outline-none"
51
+ >
52
+ Mark all as read
53
+ </button>
54
+ ) : (
55
+ <span className="text-xs text-gray-400">All read</span>
56
+ )}
57
+ </div>
58
+
59
+ {/* Notification list */}
60
+ <div className="max-h-80 overflow-y-auto">
61
+ {loading ? (
62
+ <div className="px-4 py-8 text-center">
63
+ <svg
64
+ className="animate-spin h-6 w-6 mx-auto text-blue-500"
65
+ fill="none"
66
+ viewBox="0 0 24 24"
67
+ >
68
+ <circle
69
+ className="opacity-25"
70
+ cx="12"
71
+ cy="12"
72
+ r="10"
73
+ stroke="currentColor"
74
+ strokeWidth="4"
75
+ />
76
+ <path
77
+ className="opacity-75"
78
+ fill="currentColor"
79
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
80
+ />
81
+ </svg>
82
+ <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading...</p>
83
+ </div>
84
+ ) : notifications.length === 0 ? (
85
+ <div className="px-4 py-8 text-center">
86
+ <svg
87
+ className="mx-auto h-8 w-8 text-gray-400"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ viewBox="0 0 24 24"
91
+ >
92
+ <path
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ strokeWidth={2}
96
+ d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
97
+ />
98
+ </svg>
99
+ <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">No notifications</p>
100
+ <p className="text-xs text-gray-400 dark:text-gray-500">You're all caught up!</p>
101
+ </div>
102
+ ) : (
103
+ <div className="group">
104
+ {notifications.map((notification) => (
105
+ <NotificationItem
106
+ key={notification.id}
107
+ notification={notification}
108
+ isRead={readIds.has(notification.id)}
109
+ onMarkRead={onMarkRead}
110
+ compact
111
+ />
112
+ ))}
113
+ </div>
114
+ )}
115
+ </div>
116
+
117
+ {/* Footer */}
118
+ <div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
119
+ <a
120
+ href="/notifications"
121
+ className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
122
+ >
123
+ View all notifications
124
+ </a>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export default NotificationDropdown;
@@ -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
+ }