@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,293 @@
1
+ /**
2
+ * Healthchecks Status Component
3
+ * Shows Gatus heartbeat statuses with flip history
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+ import { fetchHealthchecks, fetchHealthcheckFlips, type FlipsResponse } from '../../lib/infrastructure/api';
7
+ import type { HealthcheckJob } from '../../lib/infrastructure/types';
8
+
9
+ const statusColours: Record<string, string> = {
10
+ up: 'bg-green-500',
11
+ down: 'bg-red-500',
12
+ grace: 'bg-yellow-500',
13
+ paused: 'bg-gray-400',
14
+ new: 'bg-blue-500',
15
+ };
16
+
17
+ const statusText: Record<string, string> = {
18
+ up: 'Healthy',
19
+ down: 'Down',
20
+ grace: 'Grace Period',
21
+ paused: 'Paused',
22
+ new: 'New',
23
+ };
24
+
25
+ function formatPeriod(seconds: number): string {
26
+ if (seconds < 60) return `${seconds}s`;
27
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
28
+ if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
29
+ return `${Math.round(seconds / 86400)}d`;
30
+ }
31
+
32
+ function formatTimeSince(timestamp: number | null): string {
33
+ if (!timestamp) return 'Never';
34
+ const now = Math.floor(Date.now() / 1000);
35
+ const diff = now - timestamp;
36
+ if (diff < 60) return 'Just now';
37
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
38
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
39
+ return `${Math.floor(diff / 86400)}d ago`;
40
+ }
41
+
42
+ function formatTimestamp(timestamp: number): string {
43
+ const date = new Date(timestamp * 1000);
44
+ const now = new Date();
45
+ const isToday = date.toDateString() === now.toDateString();
46
+
47
+ if (isToday) {
48
+ return date.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit' });
49
+ }
50
+ return date.toLocaleDateString('en-AU', {
51
+ day: 'numeric',
52
+ month: 'short',
53
+ hour: '2-digit',
54
+ minute: '2-digit'
55
+ });
56
+ }
57
+
58
+ interface FlipHistoryProps {
59
+ checkId: string;
60
+ isExpanded: boolean;
61
+ }
62
+
63
+ function FlipHistory({ checkId, isExpanded }: FlipHistoryProps) {
64
+ const [flipsData, setFlipsData] = useState<FlipsResponse | null>(null);
65
+ const [loading, setLoading] = useState(false);
66
+ const [error, setError] = useState<string | null>(null);
67
+
68
+ useEffect(() => {
69
+ if (!isExpanded) return;
70
+
71
+ async function loadFlips() {
72
+ setLoading(true);
73
+ setError(null);
74
+ try {
75
+ const data = await fetchHealthcheckFlips(checkId);
76
+ setFlipsData(data);
77
+ } catch (e) {
78
+ setError(e instanceof Error ? e.message : 'Failed to load flips');
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ }
83
+
84
+ loadFlips();
85
+ }, [checkId, isExpanded]);
86
+
87
+ if (!isExpanded) return null;
88
+
89
+ if (loading) {
90
+ return (
91
+ <div className="px-4 pb-4 pt-0">
92
+ <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 animate-pulse">
93
+ <div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-2"></div>
94
+ <div className="h-3 bg-gray-200 dark:bg-gray-600 rounded w-48"></div>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ if (error) {
101
+ return (
102
+ <div className="px-4 pb-4 pt-0">
103
+ <div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 text-sm text-red-600 dark:text-red-400">
104
+ {error}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ if (!flipsData || flipsData.flips.length === 0) {
111
+ return (
112
+ <div className="px-4 pb-4 pt-0">
113
+ <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 text-sm text-gray-500 dark:text-gray-400">
114
+ No status changes recorded in the last 30 days
115
+ </div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <div className="px-4 pb-4 pt-0">
122
+ <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
123
+ <div className="flex items-center justify-between mb-2">
124
+ <h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
125
+ Recent Status Changes
126
+ </h4>
127
+ {flipsData.flipsToday > 0 && (
128
+ <span className="text-xs px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
129
+ {flipsData.flipsToday} flip{flipsData.flipsToday !== 1 ? 's' : ''} today
130
+ </span>
131
+ )}
132
+ </div>
133
+
134
+ <div className="space-y-1">
135
+ {flipsData.flips.slice(0, 10).map((flip, index) => (
136
+ <div
137
+ key={index}
138
+ className="flex items-center gap-2 text-sm"
139
+ >
140
+ <span className={`w-2 h-2 rounded-full ${flip.up ? 'bg-green-500' : 'bg-red-500'}`}></span>
141
+ <span className={flip.up ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
142
+ {flip.up ? 'Recovered' : 'Failed'}
143
+ </span>
144
+ <span className="text-gray-500 dark:text-gray-400">
145
+ {formatTimestamp(flip.timestamp)}
146
+ </span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+
151
+ {flipsData.lastFailure && (
152
+ <div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-600 text-xs text-gray-500 dark:text-gray-400">
153
+ Last failure: {formatTimeSince(flipsData.lastFailure)}
154
+ </div>
155
+ )}
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ export function HealthchecksStatus() {
162
+ const [checks, setChecks] = useState<HealthcheckJob[]>([]);
163
+ const [loading, setLoading] = useState(true);
164
+ const [error, setError] = useState<string | null>(null);
165
+ const [expandedCheck, setExpandedCheck] = useState<string | null>(null);
166
+
167
+ useEffect(() => {
168
+ async function load() {
169
+ try {
170
+ const data = await fetchHealthchecks();
171
+ setChecks(data);
172
+ } catch (e) {
173
+ setError(e instanceof Error ? e.message : 'Unknown error');
174
+ } finally {
175
+ setLoading(false);
176
+ }
177
+ }
178
+ load();
179
+ }, []);
180
+
181
+ if (loading) {
182
+ return (
183
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
184
+ <div className="space-y-3">
185
+ {[...Array(3)].map((_, i) => (
186
+ <div key={i} className="animate-pulse flex items-center gap-3">
187
+ <div className="w-3 h-3 rounded-full bg-gray-200 dark:bg-gray-700"></div>
188
+ <div className="flex-1 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ if (error) {
197
+ return (
198
+ <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">
199
+ Error loading healthchecks: {error}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ if (checks.length === 0) {
205
+ return (
206
+ <div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
207
+ <span className="text-4xl mb-2 block">&#x23F0;</span>
208
+ <p className="text-gray-600 dark:text-gray-400">No cron jobs configured</p>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ const upCount = checks.filter((c) => c.status === 'up').length;
214
+ const downCount = checks.filter((c) => c.status === 'down').length;
215
+
216
+ return (
217
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
218
+ {/* Summary Header */}
219
+ <div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
220
+ <div>
221
+ <span className="text-lg font-semibold text-gray-900 dark:text-white">
222
+ {upCount}/{checks.length} Healthy
223
+ </span>
224
+ {downCount > 0 && (
225
+ <span className="ml-3 text-sm text-red-600 dark:text-red-400">
226
+ {downCount} down
227
+ </span>
228
+ )}
229
+ </div>
230
+ <a
231
+ href="https://status.littlebearapps.com"
232
+ target="_blank"
233
+ rel="noopener noreferrer"
234
+ className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
235
+ >
236
+ Open Status Page
237
+ </a>
238
+ </div>
239
+
240
+ {/* Check List */}
241
+ <div className="divide-y divide-gray-200 dark:divide-gray-700">
242
+ {checks.map((check) => (
243
+ <div key={check.id}>
244
+ <div
245
+ className="p-4 flex items-center gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
246
+ onClick={() => setExpandedCheck(expandedCheck === check.id ? null : check.id)}
247
+ >
248
+ <div className={`w-3 h-3 rounded-full ${statusColours[check.status]}`} title={statusText[check.status]}></div>
249
+ <div className="flex-1 min-w-0">
250
+ <div className="flex items-center gap-2">
251
+ <p className="font-medium text-gray-900 dark:text-white truncate">{check.name}</p>
252
+ {/* Show indicator if check has recent activity */}
253
+ {check.status === 'down' && (
254
+ <span className="px-1.5 py-0.5 text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
255
+ DOWN
256
+ </span>
257
+ )}
258
+ </div>
259
+ <div className="flex items-center gap-2 mt-1">
260
+ {check.tags.map((tag) => (
261
+ <span key={tag} className="px-1.5 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
262
+ {tag}
263
+ </span>
264
+ ))}
265
+ </div>
266
+ </div>
267
+ <div className="text-right">
268
+ <p className="text-sm text-gray-700 dark:text-gray-300">
269
+ Every {formatPeriod(check.period)}
270
+ </p>
271
+ <p className="text-xs text-gray-500 dark:text-gray-400">
272
+ Last: {formatTimeSince(check.lastPing)}
273
+ </p>
274
+ </div>
275
+ {/* Expand indicator */}
276
+ <svg
277
+ className={`w-5 h-5 text-gray-400 transition-transform ${expandedCheck === check.id ? 'rotate-180' : ''}`}
278
+ fill="none"
279
+ stroke="currentColor"
280
+ viewBox="0 0 24 24"
281
+ >
282
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
283
+ </svg>
284
+ </div>
285
+
286
+ {/* Flip History (expandable) */}
287
+ <FlipHistory checkId={check.id} isExpanded={expandedCheck === check.id} />
288
+ </div>
289
+ ))}
290
+ </div>
291
+ </div>
292
+ );
293
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Infrastructure Tabs Component
3
+ * Organises infrastructure content into navigable tabs
4
+ */
5
+ import { useState, useEffect } from 'react';
6
+ import { InfrastructureStats } from './InfrastructureStats';
7
+ import { ServiceRegistry } from './ServiceRegistry';
8
+ import { UptimeStatus } from './UptimeStatus';
9
+ import { HealthchecksStatus } from './HealthchecksStatus';
10
+ import { AlertHistory } from './AlertHistory';
11
+
12
+ type TabId = 'overview' | 'monitoring' | 'services' | 'alerts';
13
+
14
+ interface Tab {
15
+ id: TabId;
16
+ label: string;
17
+ icon: string;
18
+ }
19
+
20
+ const tabs: Tab[] = [
21
+ { id: 'overview', label: 'Overview', icon: '📊' },
22
+ { id: 'monitoring', label: 'Monitoring', icon: '📡' },
23
+ { id: 'services', label: 'Services', icon: '⚙️' },
24
+ { id: 'alerts', label: 'Alerts', icon: '🔔' },
25
+ ];
26
+
27
+ interface AutomationData {
28
+ cloakpipeErrors: number;
29
+ homeostatTotals: { total_fixes: number; successful_fixes: number };
30
+ gatekeeperTotals: { total_publishes: number; successful_publishes: number };
31
+ }
32
+
33
+ interface Props {
34
+ automationData?: AutomationData;
35
+ }
36
+
37
+ export function InfrastructureTabs({ automationData }: Props) {
38
+ const [activeTab, setActiveTab] = useState<TabId>('overview');
39
+
40
+ // Sync with URL hash
41
+ useEffect(() => {
42
+ const hash = window.location.hash.slice(1) as TabId;
43
+ if (hash && tabs.some((t) => t.id === hash)) {
44
+ setActiveTab(hash);
45
+ }
46
+ }, []);
47
+
48
+ const handleTabChange = (tabId: TabId) => {
49
+ setActiveTab(tabId);
50
+ window.history.replaceState(null, '', `#${tabId}`);
51
+ };
52
+
53
+ return (
54
+ <div className="space-y-6">
55
+ {/* Tab Navigation */}
56
+ <div className="border-b border-gray-200 dark:border-gray-700">
57
+ <nav className="-mb-px flex space-x-8" aria-label="Tabs">
58
+ {tabs.map((tab) => (
59
+ <button
60
+ key={tab.id}
61
+ onClick={() => handleTabChange(tab.id)}
62
+ className={`
63
+ whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm
64
+ ${
65
+ activeTab === tab.id
66
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
67
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
68
+ }
69
+ `}
70
+ >
71
+ <span className="mr-2">{tab.icon}</span>
72
+ {tab.label}
73
+ </button>
74
+ ))}
75
+ </nav>
76
+ </div>
77
+
78
+ {/* Tab Content */}
79
+ <div className="min-h-[400px]">
80
+ {activeTab === 'overview' && (
81
+ <OverviewTab
82
+ automationData={automationData}
83
+ onNavigate={handleTabChange}
84
+ />
85
+ )}
86
+ {activeTab === 'monitoring' && <MonitoringTab />}
87
+ {activeTab === 'services' && <ServicesTab />}
88
+ {activeTab === 'alerts' && <AlertsTab />}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ // Overview Tab - Summary of everything
95
+ function OverviewTab({
96
+ automationData,
97
+ onNavigate,
98
+ }: {
99
+ automationData?: AutomationData;
100
+ onNavigate: (tab: TabId) => void;
101
+ }) {
102
+ return (
103
+ <div className="space-y-6">
104
+ {/* Stats Cards */}
105
+ <InfrastructureStats />
106
+
107
+ {/* Quick Status Cards */}
108
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
109
+ {/* Monitoring Quick View */}
110
+ <QuickViewCard
111
+ title="Monitoring"
112
+ icon="📡"
113
+ onClick={() => onNavigate('monitoring')}
114
+ >
115
+ <p className="text-sm text-gray-600 dark:text-gray-400">
116
+ View uptime monitors and cron job health
117
+ </p>
118
+ </QuickViewCard>
119
+
120
+ {/* Services Quick View */}
121
+ <QuickViewCard
122
+ title="Services"
123
+ icon="⚙️"
124
+ onClick={() => onNavigate('services')}
125
+ >
126
+ <p className="text-sm text-gray-600 dark:text-gray-400">
127
+ Browse and filter the service registry
128
+ </p>
129
+ </QuickViewCard>
130
+
131
+ {/* Alerts Quick View */}
132
+ <QuickViewCard
133
+ title="Alerts"
134
+ icon="🔔"
135
+ onClick={() => onNavigate('alerts')}
136
+ >
137
+ <p className="text-sm text-gray-600 dark:text-gray-400">
138
+ View and acknowledge recent alerts
139
+ </p>
140
+ </QuickViewCard>
141
+ </div>
142
+
143
+ {/* Automation Pipeline (Legacy) - Collapsed by default */}
144
+ {automationData && (
145
+ <details className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
146
+ <summary className="px-4 py-3 cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
147
+ Automation Pipeline (CloakPipe/HomeoStat/GateKeeper)
148
+ </summary>
149
+ <div className="px-4 pb-4">
150
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
151
+ <AutomationCard
152
+ title="CloakPipe"
153
+ value={automationData.cloakpipeErrors}
154
+ label="Errors logged (24h)"
155
+ />
156
+ <AutomationCard
157
+ title="HomeoStat"
158
+ value={`${automationData.homeostatTotals.successful_fixes}/${automationData.homeostatTotals.total_fixes}`}
159
+ label="Successful fixes (24h)"
160
+ />
161
+ <AutomationCard
162
+ title="GateKeeper"
163
+ value={`${automationData.gatekeeperTotals.successful_publishes}/${automationData.gatekeeperTotals.total_publishes}`}
164
+ label="Successful publishes (24h)"
165
+ />
166
+ </div>
167
+ </div>
168
+ </details>
169
+ )}
170
+ </div>
171
+ );
172
+ }
173
+
174
+ // Monitoring Tab - Uptime + Healthchecks
175
+ function MonitoringTab() {
176
+ return (
177
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
178
+ <section>
179
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
180
+ Uptime &amp; Services
181
+ </h2>
182
+ <UptimeStatus />
183
+ </section>
184
+
185
+ <section>
186
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
187
+ Cron Jobs &amp; Servers
188
+ </h2>
189
+ <HealthchecksStatus />
190
+ </section>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ // Services Tab - Full Service Registry
196
+ function ServicesTab() {
197
+ return (
198
+ <section>
199
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
200
+ Service Registry
201
+ </h2>
202
+ <ServiceRegistry />
203
+ </section>
204
+ );
205
+ }
206
+
207
+ // Alerts Tab - Alert History
208
+ function AlertsTab() {
209
+ return (
210
+ <section>
211
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
212
+ Alert History
213
+ </h2>
214
+ <AlertHistory />
215
+ </section>
216
+ );
217
+ }
218
+
219
+ // Helper Components
220
+ function QuickViewCard({
221
+ title,
222
+ icon,
223
+ onClick,
224
+ children,
225
+ }: {
226
+ title: string;
227
+ icon: string;
228
+ onClick: () => void;
229
+ children: React.ReactNode;
230
+ }) {
231
+ return (
232
+ <button
233
+ onClick={onClick}
234
+ className="w-full text-left bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
235
+ >
236
+ <div className="flex items-center gap-2 mb-2">
237
+ <span>{icon}</span>
238
+ <h3 className="font-medium text-gray-900 dark:text-white">{title}</h3>
239
+ <span className="ml-auto text-gray-400">→</span>
240
+ </div>
241
+ {children}
242
+ </button>
243
+ );
244
+ }
245
+
246
+ function AutomationCard({
247
+ title,
248
+ value,
249
+ label,
250
+ }: {
251
+ title: string;
252
+ value: number | string;
253
+ label: string;
254
+ }) {
255
+ return (
256
+ <div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
257
+ <div className="text-sm font-medium text-gray-600 dark:text-gray-400">
258
+ {title}
259
+ </div>
260
+ <div className="text-xl font-bold mt-1 text-gray-900 dark:text-white">
261
+ {value}
262
+ </div>
263
+ <div className="mt-1 text-xs text-gray-500 dark:text-gray-500">
264
+ {label}
265
+ </div>
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,64 @@
1
+ ---
2
+ import DashboardLayout from '../layouts/DashboardLayout.astro';
3
+
4
+ interface ProductMetricRow {
5
+ source: string;
6
+ metric_type: string;
7
+ value: number;
8
+ timestamp: number;
9
+ }
10
+
11
+ const runtime = (Astro.locals as App.Locals | undefined)?.runtime;
12
+ const db = runtime?.env?.PLATFORM_DB;
13
+
14
+ const metricsByType: Record<string, number> = {};
15
+
16
+ if (db) {
17
+ const result = await db
18
+ .prepare(
19
+ `SELECT source, metric_type, value, timestamp
20
+ FROM product_metrics
21
+ WHERE source IN ('plausible', 'ga4')
22
+ ORDER BY timestamp DESC
23
+ LIMIT 100`
24
+ )
25
+ .all<ProductMetricRow>();
26
+
27
+ for (const row of result.results ?? []) {
28
+ const key = `${row.source}:${row.metric_type}`;
29
+ if (!(key in metricsByType)) {
30
+ metricsByType[key] = row.value ?? 0;
31
+ }
32
+ }
33
+ }
34
+
35
+ const pageviews = metricsByType['plausible:pageviews'] ?? 0;
36
+ const activeUsers = metricsByType['plausible:active_users'] ?? 0;
37
+ const installs = metricsByType['ga4:installs'] ?? 0;
38
+ ---
39
+
40
+ <DashboardLayout title="Analytics">
41
+ <div class="space-y-6">
42
+ <h2 class="text-3xl font-bold text-gray-900 dark:text-white">Product Analytics</h2>
43
+
44
+ <div class="grid grid-cols-1 gap-6 md:grid-cols-3">
45
+ <div class="metric-card">
46
+ <div class="metric-title">Pageviews</div>
47
+ <div class="metric-value">{pageviews.toLocaleString()}</div>
48
+ <div class="mt-2 text-sm text-gray-600 dark:text-gray-400">Last 30 days (Plausible)</div>
49
+ </div>
50
+
51
+ <div class="metric-card">
52
+ <div class="metric-title">Active Users</div>
53
+ <div class="metric-value">{activeUsers.toLocaleString()}</div>
54
+ <div class="mt-2 text-sm text-gray-600 dark:text-gray-400">Last 30 days (Plausible)</div>
55
+ </div>
56
+
57
+ <div class="metric-card">
58
+ <div class="metric-title">Chrome Installs</div>
59
+ <div class="metric-value">{installs.toLocaleString()}</div>
60
+ <div class="mt-2 text-sm text-gray-600 dark:text-gray-400">Last 30 days (GA4)</div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </DashboardLayout>