@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * LiveHeader Component
3
+ *
4
+ * Header bar with live indicator, period buttons, search, and export.
5
+ * Industrial Command Centre aesthetic.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback } from 'react';
9
+ import { Activity, RefreshCw, Search, Download, X } from 'lucide-react';
10
+ import { clsx } from 'clsx';
11
+ import type { Period } from './types';
12
+
13
+ interface LiveHeaderProps {
14
+ period: Period;
15
+ onPeriodChange: (period: Period) => void;
16
+ search: string;
17
+ onSearchChange: (value: string) => void;
18
+ isRefreshing: boolean;
19
+ onExport: () => void;
20
+ }
21
+
22
+ /**
23
+ * Live indicator with pulse animation
24
+ */
25
+ function LiveIndicator({ isRefreshing }: { isRefreshing: boolean }) {
26
+ return (
27
+ <div className="flex items-center gap-2">
28
+ {isRefreshing ? (
29
+ <RefreshCw className="w-3.5 h-3.5 text-gray-600 dark:text-slate-400 animate-spin" />
30
+ ) : (
31
+ <span className="relative flex h-2.5 w-2.5">
32
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
33
+ <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
34
+ </span>
35
+ )}
36
+ <span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
37
+ {isRefreshing ? 'Updating' : 'Live'}
38
+ </span>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Period button component
45
+ */
46
+ function PeriodButton({
47
+ period,
48
+ currentPeriod,
49
+ onClick,
50
+ }: {
51
+ period: Period;
52
+ currentPeriod: Period;
53
+ onClick: (p: Period) => void;
54
+ }) {
55
+ const isActive = period === currentPeriod;
56
+ return (
57
+ <button
58
+ type="button"
59
+ onClick={() => onClick(period)}
60
+ className={clsx(
61
+ 'px-3 py-1.5 text-xs font-mono font-semibold uppercase tracking-wider rounded-sm transition-all',
62
+ isActive
63
+ ? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100 shadow-inner'
64
+ : 'bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300'
65
+ )}
66
+ >
67
+ {period}
68
+ </button>
69
+ );
70
+ }
71
+
72
+ export function LiveHeader({
73
+ period,
74
+ onPeriodChange,
75
+ search,
76
+ onSearchChange,
77
+ isRefreshing,
78
+ onExport,
79
+ }: LiveHeaderProps) {
80
+ const [localSearch, setLocalSearch] = useState(search);
81
+
82
+ // Debounce search
83
+ useEffect(() => {
84
+ const timer = setTimeout(() => {
85
+ if (localSearch !== search) {
86
+ onSearchChange(localSearch);
87
+ }
88
+ }, 300);
89
+
90
+ return () => clearTimeout(timer);
91
+ }, [localSearch, search, onSearchChange]);
92
+
93
+ // Sync external search changes
94
+ useEffect(() => {
95
+ setLocalSearch(search);
96
+ }, [search]);
97
+
98
+ const handleClearSearch = useCallback(() => {
99
+ setLocalSearch('');
100
+ onSearchChange('');
101
+ }, [onSearchChange]);
102
+
103
+ return (
104
+ <header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
105
+ {/* Left: Title and Live Indicator */}
106
+ <div className="flex items-center gap-3">
107
+ <Activity className="w-5 h-5 text-gray-600 dark:text-slate-400" />
108
+ <h1 className="text-xl font-semibold text-gray-900 dark:text-slate-100 tracking-tight">
109
+ Usage Monitor
110
+ </h1>
111
+ <LiveIndicator isRefreshing={isRefreshing} />
112
+ </div>
113
+
114
+ {/* Right: Controls */}
115
+ <div className="flex items-center gap-3">
116
+ {/* Period Buttons */}
117
+ <div className="flex items-center gap-1">
118
+ <PeriodButton period="24h" currentPeriod={period} onClick={onPeriodChange} />
119
+ <PeriodButton period="7d" currentPeriod={period} onClick={onPeriodChange} />
120
+ <PeriodButton period="30d" currentPeriod={period} onClick={onPeriodChange} />
121
+ </div>
122
+
123
+ {/* Search Input */}
124
+ <div className="relative">
125
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 dark:text-slate-500" />
126
+ <input
127
+ type="text"
128
+ value={localSearch}
129
+ onChange={(e) => setLocalSearch(e.target.value)}
130
+ placeholder="Search projects..."
131
+ className={clsx(
132
+ 'w-40 pl-8 pr-7 py-1.5 text-xs font-mono',
133
+ 'bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-sm',
134
+ 'text-gray-800 dark:text-slate-200 placeholder:text-gray-400 dark:placeholder:text-slate-500',
135
+ 'focus:outline-none focus:border-gray-400 dark:focus:border-slate-600 focus:ring-1 focus:ring-gray-400 dark:focus:ring-slate-600',
136
+ 'transition-all'
137
+ )}
138
+ />
139
+ {localSearch && (
140
+ <button
141
+ type="button"
142
+ onClick={handleClearSearch}
143
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-300 transition-colors"
144
+ >
145
+ <X className="w-3.5 h-3.5" />
146
+ </button>
147
+ )}
148
+ </div>
149
+
150
+ {/* Export Button */}
151
+ <button
152
+ type="button"
153
+ onClick={onExport}
154
+ className={clsx(
155
+ 'flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono font-semibold uppercase tracking-wider',
156
+ 'bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400 rounded-sm',
157
+ 'hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300 transition-all'
158
+ )}
159
+ title="Export to CSV"
160
+ >
161
+ <Download className="w-3.5 h-3.5" />
162
+ <span className="hidden sm:inline">Export</span>
163
+ </button>
164
+ </div>
165
+ </header>
166
+ );
167
+ }
168
+
169
+ export default LiveHeader;
@@ -0,0 +1,448 @@
1
+ /**
2
+ * ProjectsTable Component
3
+ *
4
+ * Hierarchical table with expandable rows for project resource breakdown.
5
+ * Industrial Command Centre aesthetic - data-dense, sortable columns.
6
+ */
7
+
8
+ import { useMemo, useCallback } from 'react';
9
+ import { ChevronRight, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown, Clock } from 'lucide-react';
10
+ import { clsx } from 'clsx';
11
+ import type {
12
+ ProjectTableRow,
13
+ ProjectBreakdown,
14
+ SortField,
15
+ SortDir,
16
+ OperationalStatus,
17
+ CircuitBreakerState,
18
+ } from './types';
19
+ import { STATUS_COLORS, CB_COLORS } from './types';
20
+ import { ResourceBreakdown } from './ResourceBreakdown';
21
+ import { Sparkline } from './Sparkline';
22
+
23
+ interface ProjectsTableProps {
24
+ rows: ProjectTableRow[];
25
+ expanded: Set<string>;
26
+ onExpand: (projectId: string) => void;
27
+ onSort: (field: SortField) => void;
28
+ sort: SortField;
29
+ sortDir: SortDir;
30
+ resourceCache: Map<string, ProjectBreakdown>;
31
+ fetchResourceBreakdown: (projectId: string) => Promise<ProjectBreakdown | null>;
32
+ }
33
+
34
+ function formatCost(cost: number): string {
35
+ if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
36
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
37
+ if (cost >= 0.01) return `$${cost.toFixed(3)}`;
38
+ return `$${cost.toFixed(4)}`;
39
+ }
40
+
41
+ function formatDelta(pct: number): { text: string; color: string } {
42
+ if (pct > 0) {
43
+ return { text: `+${pct.toFixed(1)}%`, color: 'text-rose-400' };
44
+ }
45
+ if (pct < 0) {
46
+ return { text: `${pct.toFixed(1)}%`, color: 'text-emerald-400' };
47
+ }
48
+ return { text: '0%', color: 'text-gray-500 dark:text-slate-500' };
49
+ }
50
+
51
+ /**
52
+ * Status Badge Component
53
+ */
54
+ function StatusBadge({ status }: { status: OperationalStatus }) {
55
+ const colors = STATUS_COLORS[status];
56
+ return (
57
+ <span
58
+ className={clsx(
59
+ 'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase',
60
+ colors.bg,
61
+ colors.text
62
+ )}
63
+ >
64
+ <span className={clsx('w-1.5 h-1.5 rounded-full', colors.text.replace('text-', 'bg-'))} />
65
+ {status}
66
+ </span>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Circuit Breaker Indicator
72
+ */
73
+ function CircuitBreakerIndicator({ state }: { state: CircuitBreakerState }) {
74
+ const label = state === 'active' ? 'CB' : state === 'tripped' ? 'TRIP' : 'No CB';
75
+
76
+ return (
77
+ <div className="flex items-center gap-1.5" title={`Circuit Breaker: ${state}`}>
78
+ <span className={clsx('w-2 h-2 rounded-full', CB_COLORS[state])} />
79
+ <span
80
+ className={clsx(
81
+ 'text-xs font-mono',
82
+ state === 'disabled'
83
+ ? 'text-gray-400 dark:text-slate-600'
84
+ : 'text-gray-500 dark:text-slate-500'
85
+ )}
86
+ >
87
+ {label}
88
+ </span>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Heartbeat Indicator - shows last seen timestamp with freshness status
95
+ */
96
+ function HeartbeatIndicator({ lastSeen }: { lastSeen?: string }) {
97
+ if (!lastSeen) {
98
+ return (
99
+ <div className="flex items-center gap-1.5" title="No heartbeat received">
100
+ <Clock className="w-3 h-3 text-gray-400 dark:text-slate-600" />
101
+ <span className="text-xs font-mono text-gray-400 dark:text-slate-600">—</span>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ const lastSeenDate = new Date(lastSeen);
107
+ const now = new Date();
108
+ const diffMs = now.getTime() - lastSeenDate.getTime();
109
+ const diffMins = Math.floor(diffMs / 60000);
110
+ const diffHours = Math.floor(diffMs / 3600000);
111
+
112
+ // Determine freshness: < 10min = fresh, < 1hr = recent, < 24hr = stale, > 24hr = dead
113
+ let status: 'fresh' | 'recent' | 'stale' | 'dead';
114
+ let label: string;
115
+
116
+ if (diffMins < 10) {
117
+ status = 'fresh';
118
+ label = diffMins <= 1 ? 'now' : `${diffMins}m`;
119
+ } else if (diffMins < 60) {
120
+ status = 'recent';
121
+ label = `${diffMins}m`;
122
+ } else if (diffHours < 24) {
123
+ status = 'stale';
124
+ label = `${diffHours}h`;
125
+ } else {
126
+ status = 'dead';
127
+ const diffDays = Math.floor(diffHours / 24);
128
+ label = `${diffDays}d`;
129
+ }
130
+
131
+ const statusColors = {
132
+ fresh: 'text-emerald-400',
133
+ recent: 'text-amber-400',
134
+ stale: 'text-orange-400',
135
+ dead: 'text-rose-400',
136
+ };
137
+
138
+ const dotColors = {
139
+ fresh: 'bg-emerald-500',
140
+ recent: 'bg-amber-500',
141
+ stale: 'bg-orange-500',
142
+ dead: 'bg-rose-500',
143
+ };
144
+
145
+ return (
146
+ <div
147
+ className="flex items-center gap-1.5"
148
+ title={`Last heartbeat: ${lastSeenDate.toLocaleString()}`}
149
+ >
150
+ <span className={clsx('w-2 h-2 rounded-full', dotColors[status])} />
151
+ <span className={clsx('text-xs font-mono', statusColors[status])}>{label}</span>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Activity Bar Component
158
+ */
159
+ function ActivityBar({ value, max = 100 }: { value: number; max?: number }) {
160
+ const pct = Math.min((value / max) * 100, 100);
161
+ const barColor =
162
+ pct >= 90
163
+ ? 'bg-rose-500'
164
+ : pct >= 75
165
+ ? 'bg-orange-500'
166
+ : pct >= 50
167
+ ? 'bg-amber-500'
168
+ : 'bg-emerald-500';
169
+
170
+ return (
171
+ <div className="w-20 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
172
+ <div
173
+ className={clsx('h-full rounded-full transition-all', barColor)}
174
+ style={{ width: `${pct}%` }}
175
+ />
176
+ </div>
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Sortable Column Header
182
+ */
183
+ function SortHeader({
184
+ field,
185
+ label,
186
+ currentSort,
187
+ sortDir,
188
+ onSort,
189
+ align = 'left',
190
+ }: {
191
+ field: SortField;
192
+ label: string;
193
+ currentSort: SortField;
194
+ sortDir: SortDir;
195
+ onSort: (field: SortField) => void;
196
+ align?: 'left' | 'right';
197
+ }) {
198
+ const isActive = field === currentSort;
199
+
200
+ return (
201
+ <button
202
+ type="button"
203
+ onClick={() => onSort(field)}
204
+ className={clsx(
205
+ 'flex items-center gap-1 text-xs font-mono uppercase tracking-wider transition-colors',
206
+ isActive
207
+ ? 'text-gray-800 dark:text-slate-200'
208
+ : 'text-gray-500 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-400',
209
+ align === 'right' && 'flex-row-reverse'
210
+ )}
211
+ >
212
+ <span>{label}</span>
213
+ {isActive ? (
214
+ sortDir === 'asc' ? (
215
+ <ArrowUp className="w-3 h-3" />
216
+ ) : (
217
+ <ArrowDown className="w-3 h-3" />
218
+ )
219
+ ) : (
220
+ <ArrowUpDown className="w-3 h-3 opacity-50" />
221
+ )}
222
+ </button>
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Project Row Component
228
+ */
229
+ function ProjectRow({
230
+ row,
231
+ isExpanded,
232
+ onToggle,
233
+ breakdown,
234
+ isLoadingBreakdown,
235
+ }: {
236
+ row: ProjectTableRow;
237
+ isExpanded: boolean;
238
+ onToggle: () => void;
239
+ breakdown: ProjectBreakdown | null;
240
+ isLoadingBreakdown: boolean;
241
+ }) {
242
+ const delta = formatDelta(row.costDeltaPct);
243
+
244
+ const handleRowClick = (e: React.MouseEvent) => {
245
+ e.preventDefault();
246
+ e.stopPropagation();
247
+ onToggle();
248
+ };
249
+
250
+ return (
251
+ <>
252
+ {/* Main Row */}
253
+ <tr
254
+ className={clsx(
255
+ 'border-b border-gray-200 dark:border-slate-800 transition-colors cursor-pointer',
256
+ isExpanded
257
+ ? 'bg-gray-100/50 dark:bg-slate-800/50'
258
+ : 'hover:bg-gray-100/30 dark:hover:bg-slate-800/30'
259
+ )}
260
+ onClick={handleRowClick}
261
+ >
262
+ {/* Expand Chevron */}
263
+ <td className="px-3 py-3 w-10">
264
+ <button
265
+ type="button"
266
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700 transition-colors"
267
+ onClick={(e) => {
268
+ e.preventDefault();
269
+ e.stopPropagation();
270
+ onToggle();
271
+ }}
272
+ >
273
+ {isExpanded ? (
274
+ <ChevronDown className="w-4 h-4 text-gray-600 dark:text-slate-400" />
275
+ ) : (
276
+ <ChevronRight className="w-4 h-4 text-gray-500 dark:text-slate-500" />
277
+ )}
278
+ </button>
279
+ </td>
280
+
281
+ {/* Project Name */}
282
+ <td className="px-3 py-3">
283
+ <span className="text-sm font-medium text-gray-800 dark:text-slate-200">{row.name}</span>
284
+ </td>
285
+
286
+ {/* Cost */}
287
+ <td className="px-3 py-3 text-right">
288
+ <span className="text-sm font-mono text-gray-800 dark:text-slate-200">
289
+ {formatCost(row.mtdCost)}
290
+ </span>
291
+ </td>
292
+
293
+ {/* Delta */}
294
+ <td className="px-3 py-3 text-right">
295
+ <span className={clsx('text-sm font-mono font-semibold', delta.color)}>{delta.text}</span>
296
+ </td>
297
+
298
+ {/* Activity */}
299
+ <td className="px-3 py-3">
300
+ <div className="flex items-center gap-2">
301
+ <ActivityBar value={row.activity} />
302
+ {row.activityTrend.length > 0 && (
303
+ <Sparkline data={row.activityTrend} width={40} height={16} color="#3b82f6" />
304
+ )}
305
+ </div>
306
+ </td>
307
+
308
+ {/* Status */}
309
+ <td className="px-3 py-3">
310
+ <StatusBadge status={row.status} />
311
+ </td>
312
+
313
+ {/* Circuit Breaker */}
314
+ <td className="px-3 py-3">
315
+ <CircuitBreakerIndicator state={row.circuitBreaker} />
316
+ </td>
317
+
318
+ {/* Last Seen / Heartbeat */}
319
+ <td className="px-3 py-3">
320
+ <HeartbeatIndicator lastSeen={row.lastSeen} />
321
+ </td>
322
+ </tr>
323
+
324
+ {/* Expanded Resource Breakdown */}
325
+ {isExpanded && (
326
+ <tr className="bg-gray-50 dark:bg-slate-900/50">
327
+ <td colSpan={8} className="px-6 py-4">
328
+ <ResourceBreakdown
329
+ projectId={row.id}
330
+ breakdown={breakdown}
331
+ isLoading={isLoadingBreakdown}
332
+ />
333
+ </td>
334
+ </tr>
335
+ )}
336
+ </>
337
+ );
338
+ }
339
+
340
+ export function ProjectsTable({
341
+ rows,
342
+ expanded,
343
+ onExpand,
344
+ onSort,
345
+ sort,
346
+ sortDir,
347
+ resourceCache,
348
+ }: ProjectsTableProps) {
349
+ const handleToggle = useCallback(
350
+ (projectId: string) => {
351
+ onExpand(projectId);
352
+ },
353
+ [onExpand]
354
+ );
355
+
356
+ // Determine which rows are loading their breakdown
357
+ const loadingRows = useMemo(() => {
358
+ const loading = new Set<string>();
359
+ for (const id of expanded) {
360
+ if (!resourceCache.has(id)) {
361
+ loading.add(id);
362
+ }
363
+ }
364
+ return loading;
365
+ }, [expanded, resourceCache]);
366
+
367
+ if (rows.length === 0) {
368
+ return (
369
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-8 text-center">
370
+ <p className="text-gray-500 dark:text-slate-500 text-sm">No projects found</p>
371
+ </div>
372
+ );
373
+ }
374
+
375
+ return (
376
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm overflow-hidden">
377
+ <table className="w-full">
378
+ <thead>
379
+ <tr className="border-b border-gray-300 dark:border-slate-700 bg-gray-100/50 dark:bg-slate-800/50">
380
+ <th className="px-3 py-2 w-10" /> {/* Chevron column */}
381
+ <th className="px-3 py-2 text-left">
382
+ <SortHeader
383
+ field="name"
384
+ label="Project"
385
+ currentSort={sort}
386
+ sortDir={sortDir}
387
+ onSort={onSort}
388
+ />
389
+ </th>
390
+ <th className="px-3 py-2 text-right">
391
+ <SortHeader
392
+ field="cost"
393
+ label="MTD Cost"
394
+ currentSort={sort}
395
+ sortDir={sortDir}
396
+ onSort={onSort}
397
+ align="right"
398
+ />
399
+ </th>
400
+ <th className="px-3 py-2 text-right">
401
+ <span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
402
+ Delta
403
+ </span>
404
+ </th>
405
+ <th className="px-3 py-2 text-left">
406
+ <SortHeader
407
+ field="activity"
408
+ label="Activity"
409
+ currentSort={sort}
410
+ sortDir={sortDir}
411
+ onSort={onSort}
412
+ />
413
+ </th>
414
+ <th className="px-3 py-2 text-left">
415
+ <span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
416
+ Status
417
+ </span>
418
+ </th>
419
+ <th className="px-3 py-2 text-left">
420
+ <span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
421
+ CB
422
+ </span>
423
+ </th>
424
+ <th className="px-3 py-2 text-left">
425
+ <span className="text-xs font-mono text-gray-500 dark:text-slate-500 uppercase tracking-wider">
426
+ Last Seen
427
+ </span>
428
+ </th>
429
+ </tr>
430
+ </thead>
431
+ <tbody>
432
+ {rows.map((row) => (
433
+ <ProjectRow
434
+ key={row.id}
435
+ row={row}
436
+ isExpanded={expanded.has(row.id)}
437
+ onToggle={() => handleToggle(row.id)}
438
+ breakdown={resourceCache.get(row.id) || null}
439
+ isLoadingBreakdown={loadingRows.has(row.id)}
440
+ />
441
+ ))}
442
+ </tbody>
443
+ </table>
444
+ </div>
445
+ );
446
+ }
447
+
448
+ export default ProjectsTable;