@littlebearapps/platform-admin-sdk 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +4 -7
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +206 -4
  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/DigestStats.tsx +151 -0
  9. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  10. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  11. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  12. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  13. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  14. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  15. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  16. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  17. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  18. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  19. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  20. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  21. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  22. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  23. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  24. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  25. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  26. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  27. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  28. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  30. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  31. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  32. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  34. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  35. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  36. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  37. package/templates/full/dashboard/src/pages/map.astro +561 -0
  38. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  39. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  40. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  41. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  42. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  43. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  44. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  45. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  46. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  47. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  48. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  49. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  50. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  51. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  52. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  53. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  54. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  55. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  56. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  57. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  58. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  59. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  60. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  61. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  62. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  63. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  64. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  65. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  66. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  67. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  68. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  69. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  70. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  71. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  72. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  73. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  74. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  75. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  76. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  77. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  78. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  79. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  80. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  81. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  82. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  83. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  84. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  85. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  86. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  87. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  88. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  89. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  90. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  91. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  92. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  93. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  94. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  95. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  96. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  97. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  98. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  99. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  100. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  101. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  102. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  103. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  104. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  105. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  106. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  107. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  108. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  109. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  110. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  111. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  112. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  113. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  114. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  115. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  116. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  117. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  118. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  119. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  120. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  121. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  122. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  123. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  124. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  125. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  126. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  127. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  128. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  129. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  130. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  131. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  132. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  133. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  134. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  135. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  136. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  137. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  138. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  139. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  140. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  141. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  142. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  143. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  144. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  145. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  146. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  147. package/templates/shared/tests/unit/billing.test.ts +331 -0
  148. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  149. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  150. package/templates/shared/tests/unit/control.test.ts +226 -0
  151. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  152. package/templates/shared/tests/unit/economics.test.ts +365 -0
  153. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  154. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  155. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  156. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  157. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  158. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  159. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  160. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  161. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  162. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  163. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  164. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  165. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  166. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  167. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  168. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  169. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  170. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  171. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  172. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  173. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  174. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  175. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  176. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  177. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  178. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  179. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  180. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  181. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
  182. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  183. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  184. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  185. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Unit Tests for Usage Transformers
3
+ *
4
+ * Tests the data transformation logic that powers the UnifiedResourceTable.
5
+ * These functions are the core logic for ResourceRow rendering and filtering.
6
+ *
7
+ * @module tests/unit/components/usage-transformers
8
+ * @created 2026-01-05
9
+ * @task task-17.27 - Component tests for ResourceRow/ExpandedDetails
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import {
14
+ transformToUnifiedResources,
15
+ applyComparisonData,
16
+ filterResources,
17
+ sortResources,
18
+ } from '../../../dashboard/src/components/usage/transformers';
19
+ import type { UnifiedResource } from '../../../dashboard/src/components/usage/types';
20
+
21
+ describe('Usage Transformers', () => {
22
+ describe('transformToUnifiedResources', () => {
23
+ const baseCosts = {
24
+ workers: 5.0,
25
+ d1: 2.0,
26
+ kv: 1.0,
27
+ r2: 3.0,
28
+ vectorize: 0.5,
29
+ pages: 0.0,
30
+ queues: 0.0,
31
+ workflows: 0.0,
32
+ durableObjects: 0.0,
33
+ aiGateway: 1.5,
34
+ total: 13.0,
35
+ };
36
+
37
+ const projectMapping = (name: string) => {
38
+ if (name.includes('my-project')) return 'my-project';
39
+ if (name.includes('platform')) return 'platform';
40
+ return 'other';
41
+ };
42
+
43
+ it('transforms workers data correctly', () => {
44
+ const data = {
45
+ workers: [
46
+ {
47
+ scriptName: 'my-project-api',
48
+ requests: 100000,
49
+ cpuTime: 5000,
50
+ duration: 1000,
51
+ errors: 50,
52
+ },
53
+ ],
54
+ };
55
+
56
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
57
+
58
+ expect(resources).toHaveLength(1);
59
+ expect(resources[0].type).toBe('worker');
60
+ expect(resources[0].name).toBe('my-project-api');
61
+ expect(resources[0].project).toBe('my-project');
62
+ expect(resources[0].usage.value).toBe(100000);
63
+ expect(resources[0].usage.unit).toBe('requests');
64
+ });
65
+
66
+ it('transforms D1 databases correctly', () => {
67
+ const data = {
68
+ d1: [
69
+ {
70
+ databaseId: 'db-123',
71
+ databaseName: 'my-project-db',
72
+ rowsRead: 50000,
73
+ rowsWritten: 1000,
74
+ queryCount: 5000,
75
+ },
76
+ ],
77
+ };
78
+
79
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
80
+
81
+ expect(resources).toHaveLength(1);
82
+ expect(resources[0].type).toBe('d1');
83
+ expect(resources[0].name).toBe('my-project-db');
84
+ expect(resources[0].usage.value).toBe(50000);
85
+ expect(resources[0].usage.unit).toBe('rows read');
86
+ });
87
+
88
+ it('transforms KV namespaces with total operations', () => {
89
+ const data = {
90
+ kv: [
91
+ {
92
+ namespaceId: 'kv-123',
93
+ namespaceName: 'platform-cache',
94
+ reads: 10000,
95
+ writes: 500,
96
+ deletes: 50,
97
+ lists: 100,
98
+ },
99
+ ],
100
+ };
101
+
102
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
103
+
104
+ expect(resources).toHaveLength(1);
105
+ expect(resources[0].type).toBe('kv');
106
+ expect(resources[0].usage.value).toBe(10650); // 10000 + 500 + 50 + 100
107
+ expect(resources[0].usage.unit).toBe('operations');
108
+ });
109
+
110
+ it('transforms R2 buckets with storage formatting (decimal GB)', () => {
111
+ const data = {
112
+ r2: [
113
+ {
114
+ bucketName: 'my-project-ai-logs',
115
+ storageBytes: 1000000000, // 1 GB (decimal, Cloudflare billing unit)
116
+ objectCount: 1000,
117
+ classAOperations: 500,
118
+ classBOperations: 2000,
119
+ },
120
+ ],
121
+ };
122
+
123
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
124
+
125
+ expect(resources).toHaveLength(1);
126
+ expect(resources[0].type).toBe('r2');
127
+ expect(resources[0].usage.value).toBe(1000000000);
128
+ // Uses decimal GB (1 GB = 1,000,000,000 bytes) to match Cloudflare billing
129
+ expect(resources[0].usage.formatted).toBe('1.00 GB');
130
+ });
131
+
132
+ it('transforms AI Gateway as single entry', () => {
133
+ const data = {
134
+ aiGateway: {
135
+ totalRequests: 5000,
136
+ totalTokens: 1000000,
137
+ cachedRequests: 1000,
138
+ modelBreakdown: [{ model: 'gpt-4', requests: 5000, tokens: 1000000 }],
139
+ },
140
+ };
141
+
142
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
143
+
144
+ expect(resources).toHaveLength(1);
145
+ expect(resources[0].type).toBe('ai-gateway');
146
+ expect(resources[0].name).toBe('AI Gateway');
147
+ expect(resources[0].usage.value).toBe(1000000);
148
+ });
149
+
150
+ it('calculates costs based on actual usage (requests + CPU time)', () => {
151
+ const data = {
152
+ workers: [
153
+ { scriptName: 'worker-1', requests: 1000, cpuTime: 100, duration: 10, errors: 0 },
154
+ { scriptName: 'worker-2', requests: 2000, cpuTime: 200, duration: 20, errors: 0 },
155
+ ],
156
+ };
157
+
158
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
159
+
160
+ expect(resources).toHaveLength(2);
161
+ // Costs are calculated based on actual usage: $0.30/million requests + $0.02/million CPU ms
162
+ // worker-1: (1000/1M) * 0.30 + (100/1M) * 0.02 = 0.0003 + 0.000002 = 0.000302
163
+ // worker-2: (2000/1M) * 0.30 + (200/1M) * 0.02 = 0.0006 + 0.000004 = 0.000604
164
+ expect(resources[0].costCurrent).toBeCloseTo(0.000302, 6);
165
+ expect(resources[1].costCurrent).toBeCloseTo(0.000604, 6);
166
+ });
167
+
168
+ it('handles empty data gracefully', () => {
169
+ const resources = transformToUnifiedResources({}, baseCosts, projectMapping);
170
+ expect(resources).toHaveLength(0);
171
+ });
172
+
173
+ it('sets worker status based on error rate', () => {
174
+ const data = {
175
+ workers: [
176
+ { scriptName: 'healthy', requests: 1000, cpuTime: 100, duration: 10, errors: 5 }, // 0.5% = healthy
177
+ { scriptName: 'warning', requests: 1000, cpuTime: 100, duration: 10, errors: 15 }, // 1.5% = warning
178
+ { scriptName: 'high', requests: 1000, cpuTime: 100, duration: 10, errors: 60 }, // 6% = high
179
+ { scriptName: 'critical', requests: 1000, cpuTime: 100, duration: 10, errors: 150 }, // 15% = critical
180
+ ],
181
+ };
182
+
183
+ const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
184
+
185
+ expect(resources.find((r) => r.name === 'healthy')?.status).toBe('healthy');
186
+ expect(resources.find((r) => r.name === 'warning')?.status).toBe('warning');
187
+ expect(resources.find((r) => r.name === 'high')?.status).toBe('high');
188
+ expect(resources.find((r) => r.name === 'critical')?.status).toBe('critical');
189
+ });
190
+ });
191
+
192
+ describe('applyComparisonData', () => {
193
+ const currentResources: UnifiedResource[] = [
194
+ {
195
+ id: 'worker-api',
196
+ name: 'api',
197
+ type: 'worker',
198
+ project: 'platform',
199
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
200
+ costCurrent: 5.0,
201
+ costPrior: 0,
202
+ costDelta: 0,
203
+ costDeltaPct: null,
204
+ status: 'healthy',
205
+ },
206
+ {
207
+ id: 'd1-main',
208
+ name: 'main-db',
209
+ type: 'd1',
210
+ project: 'my-project',
211
+ usage: { value: 5000, unit: 'rows read', formatted: '5K' },
212
+ costCurrent: 2.0,
213
+ costPrior: 0,
214
+ costDelta: 0,
215
+ costDeltaPct: null,
216
+ status: 'healthy',
217
+ },
218
+ ];
219
+
220
+ it('calculates cost delta percentage correctly', () => {
221
+ const priorResources: UnifiedResource[] = [
222
+ {
223
+ ...currentResources[0],
224
+ costCurrent: 4.0, // Was $4, now $5 = +25%
225
+ },
226
+ {
227
+ ...currentResources[1],
228
+ costCurrent: 2.5, // Was $2.5, now $2 = -20%
229
+ },
230
+ ];
231
+
232
+ const result = applyComparisonData(currentResources, priorResources);
233
+
234
+ expect(result[0].costDelta).toBeCloseTo(1.0);
235
+ expect(result[0].costDeltaPct).toBeCloseTo(25);
236
+
237
+ expect(result[1].costDelta).toBeCloseTo(-0.5);
238
+ expect(result[1].costDeltaPct).toBeCloseTo(-20);
239
+ });
240
+
241
+ it('marks new resources as NEW', () => {
242
+ const priorResources: UnifiedResource[] = [currentResources[0]];
243
+
244
+ const result = applyComparisonData(currentResources, priorResources);
245
+
246
+ expect(result[0].costDeltaPct).toBeCloseTo(0); // Matching resource
247
+ expect(result[1].costDeltaPct).toBe('NEW'); // New resource
248
+ });
249
+
250
+ it('handles zero prior cost correctly', () => {
251
+ const priorResources: UnifiedResource[] = [
252
+ {
253
+ ...currentResources[0],
254
+ costCurrent: 0, // Was $0, now $5
255
+ },
256
+ ];
257
+
258
+ const result = applyComparisonData([currentResources[0]], priorResources);
259
+
260
+ // When prior cost is below $0.01 threshold and current cost > 0, returns 'NEW'
261
+ // This avoids extreme/undefined percentages from near-zero baselines
262
+ expect(result[0].costDeltaPct).toBe('NEW');
263
+ });
264
+ });
265
+
266
+ describe('filterResources', () => {
267
+ const testResources: UnifiedResource[] = [
268
+ {
269
+ id: 'worker-api',
270
+ name: 'my-project-api',
271
+ type: 'worker',
272
+ project: 'my-project',
273
+ usage: { value: 1000, unit: 'requests', formatted: '1K' },
274
+ costCurrent: 5.0,
275
+ costPrior: 4.0,
276
+ costDelta: 1.0,
277
+ costDeltaPct: 25,
278
+ status: 'healthy',
279
+ },
280
+ {
281
+ id: 'd1-main',
282
+ name: 'platform-db',
283
+ type: 'd1',
284
+ project: 'platform',
285
+ usage: { value: 5000, unit: 'rows read', formatted: '5K' },
286
+ costCurrent: 0,
287
+ costPrior: 0,
288
+ costDelta: 0,
289
+ costDeltaPct: 0,
290
+ status: 'healthy',
291
+ },
292
+ {
293
+ id: 'kv-cache',
294
+ name: 'my-project-cache',
295
+ type: 'kv',
296
+ project: 'my-project',
297
+ usage: { value: 500, unit: 'operations', formatted: '500' },
298
+ costCurrent: 1.0,
299
+ costPrior: 0,
300
+ costDelta: 1.0,
301
+ costDeltaPct: 'NEW' as const,
302
+ status: 'healthy',
303
+ },
304
+ ];
305
+
306
+ it('filters by project', () => {
307
+ const result = filterResources(testResources, { project: 'my-project' });
308
+
309
+ expect(result).toHaveLength(2);
310
+ expect(result.every((r) => r.project === 'my-project')).toBe(true);
311
+ });
312
+
313
+ it('filters by service type', () => {
314
+ const result = filterResources(testResources, { serviceTypes: ['worker', 'd1'] });
315
+
316
+ expect(result).toHaveLength(2);
317
+ expect(result.some((r) => r.type === 'worker')).toBe(true);
318
+ expect(result.some((r) => r.type === 'd1')).toBe(true);
319
+ expect(result.some((r) => r.type === 'kv')).toBe(false);
320
+ });
321
+
322
+ it('filters by search query (case insensitive)', () => {
323
+ const result = filterResources(testResources, { searchQuery: 'COPILOT' });
324
+
325
+ expect(result).toHaveLength(2);
326
+ expect(result.every((r) => r.name.toLowerCase().includes('copilot'))).toBe(true);
327
+ });
328
+
329
+ it('filters by onlyChanged (>5% change or NEW)', () => {
330
+ const result = filterResources(testResources, { onlyChanged: true });
331
+
332
+ expect(result).toHaveLength(2);
333
+ // First has 25% change, third is NEW
334
+ expect(result.some((r) => r.costDeltaPct === 25)).toBe(true);
335
+ expect(result.some((r) => r.costDeltaPct === 'NEW')).toBe(true);
336
+ });
337
+
338
+ it('filters by nonZeroCost', () => {
339
+ const result = filterResources(testResources, { nonZeroCost: true });
340
+
341
+ expect(result).toHaveLength(2);
342
+ expect(result.every((r) => r.costCurrent > 0)).toBe(true);
343
+ });
344
+
345
+ it('combines multiple filters', () => {
346
+ const result = filterResources(testResources, {
347
+ project: 'my-project',
348
+ nonZeroCost: true,
349
+ });
350
+
351
+ expect(result).toHaveLength(2);
352
+ expect(result.every((r) => r.project === 'my-project' && r.costCurrent > 0)).toBe(true);
353
+ });
354
+
355
+ it('returns all resources when no filters applied', () => {
356
+ const result = filterResources(testResources, {});
357
+ expect(result).toHaveLength(3);
358
+ });
359
+ });
360
+
361
+ describe('sortResources', () => {
362
+ const testResources: UnifiedResource[] = [
363
+ {
364
+ id: '1',
365
+ name: 'bravo',
366
+ type: 'worker',
367
+ project: 'alpha',
368
+ usage: { value: 500, unit: 'requests', formatted: '500' },
369
+ costCurrent: 3.0,
370
+ costPrior: 2.0,
371
+ costDelta: 1.0,
372
+ costDeltaPct: 50,
373
+ status: 'warning',
374
+ },
375
+ {
376
+ id: '2',
377
+ name: 'alpha',
378
+ type: 'd1',
379
+ project: 'bravo',
380
+ usage: { value: 1000, unit: 'rows', formatted: '1K' },
381
+ costCurrent: 1.0,
382
+ costPrior: 1.0,
383
+ costDelta: 0,
384
+ costDeltaPct: 0,
385
+ status: 'healthy',
386
+ },
387
+ {
388
+ id: '3',
389
+ name: 'charlie',
390
+ type: 'kv',
391
+ project: 'charlie',
392
+ usage: { value: 200, unit: 'ops', formatted: '200' },
393
+ costCurrent: 5.0,
394
+ costPrior: 0,
395
+ costDelta: 5.0,
396
+ costDeltaPct: 'NEW' as const,
397
+ status: 'critical',
398
+ },
399
+ ];
400
+
401
+ it('sorts by name ascending', () => {
402
+ const result = sortResources(testResources, 'name', 'asc');
403
+
404
+ expect(result[0].name).toBe('alpha');
405
+ expect(result[1].name).toBe('bravo');
406
+ expect(result[2].name).toBe('charlie');
407
+ });
408
+
409
+ it('sorts by name descending', () => {
410
+ const result = sortResources(testResources, 'name', 'desc');
411
+
412
+ expect(result[0].name).toBe('charlie');
413
+ expect(result[1].name).toBe('bravo');
414
+ expect(result[2].name).toBe('alpha');
415
+ });
416
+
417
+ it('sorts by cost current', () => {
418
+ const result = sortResources(testResources, 'costCurrent', 'desc');
419
+
420
+ expect(result[0].costCurrent).toBe(5.0);
421
+ expect(result[1].costCurrent).toBe(3.0);
422
+ expect(result[2].costCurrent).toBe(1.0);
423
+ });
424
+
425
+ it('sorts by usage value', () => {
426
+ const result = sortResources(testResources, 'usage', 'desc');
427
+
428
+ expect(result[0].usage.value).toBe(1000);
429
+ expect(result[1].usage.value).toBe(500);
430
+ expect(result[2].usage.value).toBe(200);
431
+ });
432
+
433
+ it('sorts by costDeltaPct with NEW at end (ascending)', () => {
434
+ const result = sortResources(testResources, 'costDeltaPct', 'asc');
435
+
436
+ // 0, 50, NEW (infinity)
437
+ expect(result[0].costDeltaPct).toBe(0);
438
+ expect(result[1].costDeltaPct).toBe(50);
439
+ expect(result[2].costDeltaPct).toBe('NEW');
440
+ });
441
+
442
+ it('sorts by costDeltaPct with NEW at start (descending)', () => {
443
+ const result = sortResources(testResources, 'costDeltaPct', 'desc');
444
+
445
+ // NEW first when descending
446
+ expect(result[0].costDeltaPct).toBe('NEW');
447
+ expect(result[1].costDeltaPct).toBe(50);
448
+ expect(result[2].costDeltaPct).toBe(0);
449
+ });
450
+
451
+ it('sorts by status severity', () => {
452
+ const result = sortResources(testResources, 'status', 'desc');
453
+
454
+ expect(result[0].status).toBe('critical');
455
+ expect(result[1].status).toBe('warning');
456
+ expect(result[2].status).toBe('healthy');
457
+ });
458
+
459
+ it('sorts by type', () => {
460
+ const result = sortResources(testResources, 'type', 'asc');
461
+
462
+ expect(result[0].type).toBe('d1');
463
+ expect(result[1].type).toBe('kv');
464
+ expect(result[2].type).toBe('worker');
465
+ });
466
+
467
+ it('preserves order for unknown columns', () => {
468
+ const result = sortResources(testResources, 'unknown', 'asc');
469
+
470
+ expect(result).toHaveLength(3);
471
+ });
472
+ });
473
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Unit Tests for PID Controller
3
+ *
4
+ * Tests the stateless PID controller for intelligent degradation throttling.
5
+ *
6
+ * @module tests/unit/control
7
+ * @created 2026-01-23
8
+ * @task Intelligent Degradation for Platform Usage
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+ import {
13
+ computePID,
14
+ createPIDState,
15
+ calculateUtilisation,
16
+ shouldUpdatePID,
17
+ formatThrottleRate,
18
+ DEFAULT_PID_CONFIG,
19
+ type PIDState,
20
+ type PIDConfig,
21
+ } from '../../workers/lib/control';
22
+
23
+ describe('PID Controller', () => {
24
+ describe('createPIDState', () => {
25
+ it('creates fresh state with zero values', () => {
26
+ const state = createPIDState();
27
+
28
+ expect(state.integral).toBe(0);
29
+ expect(state.prevError).toBe(0);
30
+ expect(state.throttleRate).toBe(0);
31
+ expect(state.lastUpdate).toBeGreaterThan(0);
32
+ });
33
+ });
34
+
35
+ describe('computePID', () => {
36
+ it('returns zero throttle when usage is below setpoint', () => {
37
+ const state = createPIDState();
38
+ const input = { currentUsage: 0.5, deltaTimeMs: 60000 }; // 50% usage, setpoint is 70%
39
+
40
+ const output = computePID(state, input);
41
+
42
+ // Error is negative (0.5 - 0.7 = -0.2), so throttle should be 0 (clamped)
43
+ expect(output.throttleRate).toBe(0);
44
+ expect(output.debug.error).toBeCloseTo(-0.2, 10);
45
+ });
46
+
47
+ it('increases throttle when usage exceeds setpoint', () => {
48
+ const state = createPIDState();
49
+ const input = { currentUsage: 0.9, deltaTimeMs: 60000 }; // 90% usage
50
+
51
+ const output = computePID(state, input);
52
+
53
+ // Error is positive (0.9 - 0.7 = 0.2), should have positive throttle
54
+ expect(output.throttleRate).toBeGreaterThan(0);
55
+ expect(output.debug.error).toBeCloseTo(0.2, 10);
56
+ });
57
+
58
+ it('clamps throttle rate to [0, 1] range', () => {
59
+ const state = createPIDState();
60
+ // Extreme over-budget scenario
61
+ const input = { currentUsage: 5.0, deltaTimeMs: 60000 }; // 500% usage
62
+
63
+ const output = computePID(state, input);
64
+
65
+ expect(output.throttleRate).toBeLessThanOrEqual(1);
66
+ expect(output.throttleRate).toBeGreaterThanOrEqual(0);
67
+ });
68
+
69
+ it('accumulates integral term over time', () => {
70
+ let state = createPIDState();
71
+
72
+ // Simulate sustained over-budget usage
73
+ for (let i = 0; i < 5; i++) {
74
+ const output = computePID(state, { currentUsage: 0.85, deltaTimeMs: 60000 });
75
+ state = output.newState;
76
+ }
77
+
78
+ // Integral should have accumulated
79
+ expect(state.integral).toBeGreaterThan(0);
80
+ });
81
+
82
+ it('clamps integral to prevent windup', () => {
83
+ let state = createPIDState();
84
+
85
+ // Extreme sustained over-budget
86
+ for (let i = 0; i < 100; i++) {
87
+ const output = computePID(state, { currentUsage: 2.0, deltaTimeMs: 60000 });
88
+ state = output.newState;
89
+ }
90
+
91
+ // Integral should be clamped at integralMax (2.0)
92
+ expect(state.integral).toBeLessThanOrEqual(DEFAULT_PID_CONFIG.integralMax);
93
+ });
94
+
95
+ it('calculates derivative term for rate of change', () => {
96
+ const state: PIDState = {
97
+ integral: 0,
98
+ prevError: 0.1, // Previous error was 0.1
99
+ lastUpdate: Date.now() - 60000,
100
+ throttleRate: 0,
101
+ };
102
+
103
+ // Current error is 0.2 (rate of change = 0.1 over 60s)
104
+ const output = computePID(state, { currentUsage: 0.9, deltaTimeMs: 60000 });
105
+
106
+ // dTerm = kd * (error - prevError) / dt
107
+ // = 0.05 * (0.2 - 0.1) / 60 = 0.05 * 0.1 / 60 ≈ 0.000083
108
+ expect(output.debug.dTerm).toBeGreaterThan(0);
109
+ });
110
+
111
+ it('uses custom config when provided', () => {
112
+ const state = createPIDState();
113
+ const customConfig: PIDConfig = {
114
+ kp: 1.0,
115
+ ki: 0,
116
+ kd: 0,
117
+ setpoint: 0.5,
118
+ outputMin: 0,
119
+ outputMax: 1,
120
+ integralMax: 2.0,
121
+ };
122
+
123
+ const input = { currentUsage: 0.8, deltaTimeMs: 60000 };
124
+ const output = computePID(state, input, customConfig);
125
+
126
+ // With kp=1, error=0.3, pTerm should be 0.3
127
+ expect(output.debug.pTerm).toBeCloseTo(0.3, 10);
128
+ expect(output.debug.error).toBeCloseTo(0.3, 10);
129
+ });
130
+
131
+ it('preserves state continuity across updates', () => {
132
+ let state = createPIDState();
133
+
134
+ // First update with short deltaTime to avoid integral saturation
135
+ const output1 = computePID(state, { currentUsage: 0.8, deltaTimeMs: 1000 });
136
+ state = output1.newState;
137
+
138
+ // Second update should use previous error
139
+ const output2 = computePID(state, { currentUsage: 0.85, deltaTimeMs: 1000 });
140
+
141
+ expect(output2.newState.prevError).toBeCloseTo(0.15, 10); // 0.85 - 0.70
142
+ // Integral should accumulate (both updates have positive error)
143
+ expect(output2.newState.integral).toBeGreaterThan(output1.newState.integral);
144
+ });
145
+ });
146
+
147
+ describe('calculateUtilisation', () => {
148
+ it('calculates correct utilisation ratio', () => {
149
+ expect(calculateUtilisation(70, 100)).toBe(0.7);
150
+ expect(calculateUtilisation(100, 100)).toBe(1.0);
151
+ expect(calculateUtilisation(150, 100)).toBe(1.5);
152
+ });
153
+
154
+ it('returns 0 when limit is 0 or negative', () => {
155
+ expect(calculateUtilisation(50, 0)).toBe(0);
156
+ expect(calculateUtilisation(50, -10)).toBe(0);
157
+ });
158
+
159
+ it('handles zero usage', () => {
160
+ expect(calculateUtilisation(0, 100)).toBe(0);
161
+ });
162
+ });
163
+
164
+ describe('shouldUpdatePID', () => {
165
+ it('returns true when interval has passed', () => {
166
+ const lastUpdate = Date.now() - 65000; // 65 seconds ago
167
+ expect(shouldUpdatePID(lastUpdate, 60000)).toBe(true);
168
+ });
169
+
170
+ it('returns false when interval has not passed', () => {
171
+ const lastUpdate = Date.now() - 30000; // 30 seconds ago
172
+ expect(shouldUpdatePID(lastUpdate, 60000)).toBe(false);
173
+ });
174
+
175
+ it('returns true exactly at interval boundary', () => {
176
+ const lastUpdate = Date.now() - 60000; // Exactly 60 seconds ago
177
+ expect(shouldUpdatePID(lastUpdate, 60000)).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('formatThrottleRate', () => {
182
+ it('formats rate as percentage with one decimal', () => {
183
+ expect(formatThrottleRate(0)).toBe('0.0%');
184
+ expect(formatThrottleRate(0.5)).toBe('50.0%');
185
+ expect(formatThrottleRate(1)).toBe('100.0%');
186
+ expect(formatThrottleRate(0.123)).toBe('12.3%');
187
+ });
188
+ });
189
+
190
+ describe('PID Convergence', () => {
191
+ it('converges to stable throttle under constant load', () => {
192
+ let state = createPIDState();
193
+ const throttleRates: number[] = [];
194
+
195
+ // Simulate 10 minutes of constant 85% usage
196
+ for (let i = 0; i < 10; i++) {
197
+ const output = computePID(state, { currentUsage: 0.85, deltaTimeMs: 60000 });
198
+ state = output.newState;
199
+ throttleRates.push(output.throttleRate);
200
+ }
201
+
202
+ // After convergence, throttle rate should stabilise
203
+ const last3 = throttleRates.slice(-3);
204
+ const variance = Math.max(...last3) - Math.min(...last3);
205
+ expect(variance).toBeLessThan(0.05); // Less than 5% variance
206
+ });
207
+
208
+ it('responds quickly to sudden usage spike', () => {
209
+ let state = createPIDState();
210
+
211
+ // Normal usage for a while
212
+ for (let i = 0; i < 5; i++) {
213
+ const output = computePID(state, { currentUsage: 0.5, deltaTimeMs: 60000 });
214
+ state = output.newState;
215
+ }
216
+
217
+ const throttleBefore = state.throttleRate;
218
+
219
+ // Sudden spike to 150% usage
220
+ const output = computePID(state, { currentUsage: 1.5, deltaTimeMs: 60000 });
221
+
222
+ // Throttle should increase significantly
223
+ expect(output.throttleRate).toBeGreaterThan(throttleBefore + 0.2);
224
+ });
225
+ });
226
+ });