@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,166 @@
1
+ type StatementRecord = { sql: string; params: unknown[] };
2
+
3
+ type QueuedResult = {
4
+ type: 'first';
5
+ value: Record<string, unknown> | null;
6
+ };
7
+
8
+ type QueuedRunResult = {
9
+ type: 'run';
10
+ value: { success: boolean; results?: unknown[] };
11
+ };
12
+
13
+ type QueuedAllResult = {
14
+ type: 'all';
15
+ value: { results: unknown[] };
16
+ };
17
+
18
+ class MockPreparedStatement {
19
+ private boundParams: unknown[] = [];
20
+
21
+ constructor(
22
+ private readonly db: MockD1Database,
23
+ private readonly sql: string
24
+ ) {}
25
+
26
+ bind(...params: unknown[]): MockPreparedStatement {
27
+ this.boundParams = params;
28
+ return this;
29
+ }
30
+
31
+ async run(): Promise<{ success: boolean; results?: unknown[] }> {
32
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
33
+ const queued = this.db.dequeueResult('run');
34
+ return queued?.value ?? { success: true, results: [] };
35
+ }
36
+
37
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
38
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
39
+ const queued = this.db.dequeueResult('first');
40
+ return (queued?.value as T | null) ?? null;
41
+ }
42
+
43
+ async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
44
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
45
+ const queued = this.db.dequeueResult('all');
46
+ return (queued?.value as { results: T[] }) ?? { results: [] as T[] };
47
+ }
48
+ }
49
+
50
+ export class MockD1Database {
51
+ statements: StatementRecord[] = [];
52
+ private queuedResults: Array<QueuedResult | QueuedRunResult | QueuedAllResult> = [];
53
+
54
+ prepare(query: string): MockPreparedStatement {
55
+ return new MockPreparedStatement(this, query);
56
+ }
57
+
58
+ // These methods are required for the SDK's isD1Database type guard.
59
+ // Without them, the SDK wraps all methods in async circuit-breaker wrappers,
60
+ // which breaks the mock's synchronous dequeueResult/queueFirstResult methods.
61
+ async batch(_statements: unknown[]): Promise<unknown[]> {
62
+ return [];
63
+ }
64
+
65
+ // D1Database.exec method stub
66
+ async execBatch(_query: string): Promise<unknown> {
67
+ return { count: 0 };
68
+ }
69
+
70
+ // Alias for the SDK type guard check (expects 'exec' property)
71
+ get exec(): (_query: string) => Promise<unknown> {
72
+ return this.execBatch.bind(this);
73
+ }
74
+
75
+ queueFirstResult(value: Record<string, unknown> | null): void {
76
+ this.queuedResults.push({ type: 'first', value });
77
+ }
78
+
79
+ queueRunResult(value: { success: boolean; results?: unknown[] }): void {
80
+ this.queuedResults.push({ type: 'run', value });
81
+ }
82
+
83
+ queueAllResult(value: { results: unknown[] }): void {
84
+ this.queuedResults.push({ type: 'all', value });
85
+ }
86
+
87
+ dequeueResult(type: 'first'): QueuedResult | undefined;
88
+ dequeueResult(type: 'run'): QueuedRunResult | undefined;
89
+ dequeueResult(type: 'all'): QueuedAllResult | undefined;
90
+ dequeueResult(
91
+ type: 'first' | 'run' | 'all'
92
+ ): QueuedResult | QueuedRunResult | QueuedAllResult | undefined {
93
+ const index = this.queuedResults.findIndex((item) => item.type === type);
94
+ if (index === -1) {
95
+ return undefined;
96
+ }
97
+
98
+ return this.queuedResults.splice(index, 1)[0];
99
+ }
100
+ }
101
+
102
+ export class MockKVNamespace {
103
+ store = new Map<string, string>();
104
+
105
+ async get(_key: string): Promise<string | null> {
106
+ return null;
107
+ }
108
+
109
+ async put(key: string, value: string, _options?: unknown): Promise<void> {
110
+ this.store.set(key, value);
111
+ }
112
+
113
+ async delete(key: string): Promise<void> {
114
+ this.store.delete(key);
115
+ }
116
+
117
+ async list(): Promise<{ keys: string[]; list_complete: boolean }> {
118
+ return { keys: [], list_complete: true };
119
+ }
120
+ }
121
+
122
+ export class MockR2Bucket {
123
+ objects = new Map<string, string>();
124
+
125
+ async put(key: string, value: string): Promise<void> {
126
+ this.objects.set(key, value);
127
+ }
128
+
129
+ async delete(key: string | string[]): Promise<void> {
130
+ if (Array.isArray(key)) {
131
+ key.forEach((k) => this.objects.delete(k));
132
+ } else {
133
+ this.objects.delete(key);
134
+ }
135
+ }
136
+ }
137
+
138
+ export class MockQueue {
139
+ messages: unknown[] = [];
140
+
141
+ async send(message: unknown): Promise<void> {
142
+ this.messages.push(message);
143
+ }
144
+
145
+ async sendBatch(messages: unknown[]): Promise<void> {
146
+ this.messages.push(...messages);
147
+ }
148
+ }
149
+
150
+ export type GitHubMonitorEnv = {
151
+ PLATFORM_DB: MockD1Database;
152
+ PLATFORM_CACHE: MockKVNamespace;
153
+ PLATFORM_ARCHIVES: MockR2Bucket;
154
+ PLATFORM_TELEMETRY: MockQueue;
155
+ GITHUB_WEBHOOK_SECRET: string;
156
+ };
157
+
158
+ export function createGitHubMonitorEnv(secret = 'test-secret'): GitHubMonitorEnv {
159
+ return {
160
+ PLATFORM_DB: new MockD1Database(),
161
+ PLATFORM_CACHE: new MockKVNamespace(),
162
+ PLATFORM_ARCHIVES: new MockR2Bucket(),
163
+ PLATFORM_TELEMETRY: new MockQueue(),
164
+ GITHUB_WEBHOOK_SECRET: secret,
165
+ };
166
+ }
@@ -0,0 +1,252 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ cacheData,
4
+ getCachedData,
5
+ getOrCompute,
6
+ getStoredData,
7
+ invalidateCache,
8
+ putWithMetadata,
9
+ shouldSendAlert,
10
+ type KVNamespaceLike,
11
+ } from '../../storage/kv/cache';
12
+ import {
13
+ getCircuitBreakerState,
14
+ incrementHopCount,
15
+ isServiceAllowed,
16
+ recordFailure,
17
+ recordSuccess,
18
+ resetCircuitBreaker,
19
+ updateCircuitBreakerState,
20
+ } from '../../storage/kv/circuit-breaker';
21
+
22
+ function createMockKV(): KVNamespaceLike & {
23
+ get: ReturnType<typeof vi.fn>;
24
+ put: ReturnType<typeof vi.fn>;
25
+ delete: ReturnType<typeof vi.fn>;
26
+ } {
27
+ return {
28
+ get: vi.fn(),
29
+ put: vi.fn(),
30
+ delete: vi.fn(),
31
+ } as unknown as KVNamespaceLike & {
32
+ get: ReturnType<typeof vi.fn>;
33
+ put: ReturnType<typeof vi.fn>;
34
+ delete: ReturnType<typeof vi.fn>;
35
+ };
36
+ }
37
+
38
+ describe('KV Cache helpers', () => {
39
+ let mockKV: ReturnType<typeof createMockKV>;
40
+
41
+ beforeEach(() => {
42
+ mockKV = createMockKV();
43
+ });
44
+
45
+ describe('Alert deduplication', () => {
46
+ it('allows first alert and stores marker', async () => {
47
+ mockKV.get.mockResolvedValue(null);
48
+
49
+ const result = await shouldSendAlert('test-alert', mockKV);
50
+
51
+ expect(result).toBe(true);
52
+ expect(mockKV.put).toHaveBeenCalledWith(
53
+ 'alert:test-alert',
54
+ expect.any(String),
55
+ expect.objectContaining({ expirationTtl: 3600 })
56
+ );
57
+ });
58
+
59
+ it('suppresses duplicate alerts within TTL window', async () => {
60
+ mockKV.get.mockResolvedValue('already-sent');
61
+
62
+ const result = await shouldSendAlert('duplicate-alert', mockKV);
63
+
64
+ expect(result).toBe(false);
65
+ expect(mockKV.put).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe('Cache primitives', () => {
70
+ it('stores hot data with default TTL', async () => {
71
+ await cacheData('mrr', { value: 100 }, mockKV);
72
+ expect(mockKV.put).toHaveBeenCalledWith(
73
+ 'cache:mrr',
74
+ JSON.stringify({ value: 100 }),
75
+ expect.objectContaining({ expirationTtl: 900 })
76
+ );
77
+ });
78
+
79
+ it('retrieves cached data and parses JSON', async () => {
80
+ mockKV.get.mockResolvedValue(JSON.stringify({ foo: 'bar' }));
81
+
82
+ const cached = await getCachedData<{ foo: string }>('demo', mockKV);
83
+
84
+ expect(cached).toEqual({ foo: 'bar' });
85
+ expect(mockKV.get).toHaveBeenCalledWith('cache:demo');
86
+ });
87
+
88
+ it('supports getOrCompute pattern with cache miss', async () => {
89
+ mockKV.get.mockResolvedValueOnce(null);
90
+ const compute = vi.fn().mockResolvedValue({ total: 42 });
91
+
92
+ const result = await getOrCompute('report', compute, mockKV, 120);
93
+
94
+ expect(compute).toHaveBeenCalledTimes(1);
95
+ expect(mockKV.put).toHaveBeenCalledWith(
96
+ 'cache:report',
97
+ JSON.stringify({ total: 42 }),
98
+ expect.objectContaining({ expirationTtl: 120 })
99
+ );
100
+ expect(result).toEqual({ total: 42 });
101
+ });
102
+
103
+ it('returns cached value when available without recomputing', async () => {
104
+ mockKV.get.mockResolvedValue(JSON.stringify({ total: 99 }));
105
+ const compute = vi.fn();
106
+
107
+ const result = await getOrCompute('report', compute, mockKV);
108
+
109
+ expect(compute).not.toHaveBeenCalled();
110
+ expect(result).toEqual({ total: 99 });
111
+ });
112
+
113
+ it('invalidates cached entries', async () => {
114
+ await invalidateCache('stale', mockKV);
115
+ expect(mockKV.delete).toHaveBeenCalledWith('cache:stale');
116
+ });
117
+
118
+ it('stores and retrieves arbitrary data payloads', async () => {
119
+ mockKV.get.mockResolvedValue(JSON.stringify({ state: 'ok' }));
120
+
121
+ await putWithMetadata('status', { state: 'ok' }, mockKV, {
122
+ ttl: 30,
123
+ metadata: { scope: 'demo' },
124
+ });
125
+ expect(mockKV.put).toHaveBeenCalledWith(
126
+ 'data:status',
127
+ JSON.stringify({ state: 'ok' }),
128
+ expect.objectContaining({ expirationTtl: 30, metadata: { scope: 'demo' } })
129
+ );
130
+
131
+ const retrieved = await getStoredData<{ state: string }>('status', mockKV);
132
+ expect(retrieved).toEqual({ state: 'ok' });
133
+ });
134
+ });
135
+
136
+ describe('Circuit breaker integration', () => {
137
+ it('returns default state when none persisted', async () => {
138
+ mockKV.get.mockResolvedValue(null);
139
+
140
+ const state = await getCircuitBreakerState('cloakpipe', mockKV);
141
+
142
+ expect(state).toMatchObject({
143
+ hop_count: 0,
144
+ cooldown: false,
145
+ kill_switch: false,
146
+ consecutive_failures: 0,
147
+ });
148
+ });
149
+
150
+ it('updates stored state when incrementing hop count', async () => {
151
+ mockKV.get.mockResolvedValue(
152
+ JSON.stringify({
153
+ hop_count: 1,
154
+ cooldown: false,
155
+ kill_switch: false,
156
+ last_updated: '2025-10-25T10:00:00Z',
157
+ consecutive_failures: 0,
158
+ })
159
+ );
160
+
161
+ const state = await incrementHopCount('cloakpipe', mockKV, 3);
162
+
163
+ expect(state.hop_count).toBe(2);
164
+ expect(mockKV.put).toHaveBeenCalled();
165
+ });
166
+
167
+ it('trips circuit breaker when hop count exceeds limit', async () => {
168
+ mockKV.get.mockResolvedValue(
169
+ JSON.stringify({
170
+ hop_count: 2,
171
+ cooldown: false,
172
+ kill_switch: false,
173
+ last_updated: '2025-10-25T10:00:00Z',
174
+ consecutive_failures: 0,
175
+ })
176
+ );
177
+
178
+ const state = await incrementHopCount('homeostat', mockKV, 3);
179
+
180
+ expect(state.cooldown).toBe(true);
181
+ });
182
+
183
+ it('blocks service when kill switch active', async () => {
184
+ mockKV.get.mockResolvedValue(
185
+ JSON.stringify({
186
+ hop_count: 0,
187
+ cooldown: false,
188
+ kill_switch: true,
189
+ consecutive_failures: 0,
190
+ })
191
+ );
192
+
193
+ const allowed = await isServiceAllowed('gatekeeper', mockKV);
194
+ expect(allowed).toBe(false);
195
+ });
196
+
197
+ it('records failures and trips when threshold reached', async () => {
198
+ mockKV.get.mockResolvedValue(
199
+ JSON.stringify({
200
+ hop_count: 0,
201
+ cooldown: false,
202
+ kill_switch: false,
203
+ consecutive_failures: 4,
204
+ })
205
+ );
206
+
207
+ const state = await recordFailure('cloakpipe', mockKV, 5);
208
+ expect(state.consecutive_failures).toBe(5);
209
+ expect(state.cooldown).toBe(true);
210
+ });
211
+
212
+ it('resets failure counter on success and clears cooldown', async () => {
213
+ mockKV.get.mockResolvedValue(
214
+ JSON.stringify({
215
+ hop_count: 0,
216
+ cooldown: true,
217
+ kill_switch: false,
218
+ consecutive_failures: 2,
219
+ })
220
+ );
221
+
222
+ const state = await recordSuccess('homeostat', mockKV);
223
+ expect(state.consecutive_failures).toBe(0);
224
+ expect(state.cooldown).toBe(false);
225
+ });
226
+
227
+ it('allows manual state updates and resets', async () => {
228
+ const updatedState = {
229
+ hop_count: 1,
230
+ cooldown: true,
231
+ kill_switch: false,
232
+ last_updated: '2025-10-25T10:00:00Z',
233
+ consecutive_failures: 3,
234
+ };
235
+
236
+ await updateCircuitBreakerState('gatekeeper', updatedState, mockKV);
237
+ expect(mockKV.put).toHaveBeenCalledWith(
238
+ 'circuit:gatekeeper',
239
+ expect.any(String),
240
+ expect.objectContaining({ expirationTtl: 86400 })
241
+ );
242
+
243
+ mockKV.put.mockClear();
244
+ await resetCircuitBreaker('gatekeeper', mockKV);
245
+ expect(mockKV.put).toHaveBeenCalledWith(
246
+ 'circuit:gatekeeper',
247
+ expect.any(String),
248
+ expect.objectContaining({ expirationTtl: 86400 })
249
+ );
250
+ });
251
+ });
252
+ });