@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.
- package/README.md +2 -5
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +121 -3
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
- package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
- package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
- package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
- package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
- package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
- package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
- package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
- package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
- package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
- package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
- package/templates/full/dashboard/src/pages/feedback.astro +365 -0
- package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
- package/templates/full/dashboard/src/pages/map.astro +561 -0
- package/templates/full/dashboard/src/pages/revenue.astro +72 -0
- package/templates/full/dashboard/src/pages/tests.astro +431 -0
- package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
- package/templates/full/scripts/ops/verify-account-total.ts +256 -0
- package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
- package/templates/full/tests/integration/r2-archive.test.ts +108 -0
- package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
- package/templates/shared/.github/workflows/validate-controls.yml +27 -0
- package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
- package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
- package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
- package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
- package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
- package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
- package/templates/shared/dashboard/src/components/Toast.astro +170 -0
- package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
- package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
- package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
- package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
- package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
- package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
- package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
- package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
- package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
- package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
- package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
- package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
- package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
- package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
- package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
- package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
- package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
- package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
- package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
- package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
- package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
- package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
- package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
- package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
- package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
- package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
- package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
- package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
- package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
- package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
- package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
- package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
- package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
- package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
- package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
- package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
- package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
- package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
- package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
- package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
- package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
- package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
- package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
- package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
- package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
- package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
- package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
- package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
- package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
- package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
- package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
- package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
- package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
- package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
- package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
- package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
- package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
- package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
- package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
- package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
- package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
- package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
- package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
- package/templates/standard/tests/integration/connectors.test.ts +241 -0
- package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
- package/templates/standard/tests/integration/ingestion.test.ts +211 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { MockD1Database, MockKVNamespace } from '../helpers/mock-storage';
|
|
6
|
+
|
|
7
|
+
const schema = readFileSync(
|
|
8
|
+
join(__dirname, '..', '..', 'storage', 'd1', 'schema-feedback.sql'),
|
|
9
|
+
'utf-8'
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
describe('Feedback Events D1 Schema', () => {
|
|
13
|
+
let db: MockD1Database;
|
|
14
|
+
let kv: MockKVNamespace;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new MockD1Database();
|
|
18
|
+
kv = new MockKVNamespace();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Table Structure', () => {
|
|
22
|
+
it('should have correct schema structure', () => {
|
|
23
|
+
expect(schema).toContain('CREATE TABLE IF NOT EXISTS feedback_events');
|
|
24
|
+
expect(schema).toContain('feedback_id TEXT PRIMARY KEY');
|
|
25
|
+
expect(schema).toContain('repo TEXT NOT NULL');
|
|
26
|
+
expect(schema).toContain('issue_number INTEGER NOT NULL');
|
|
27
|
+
expect(schema).toContain('category TEXT');
|
|
28
|
+
expect(schema).toContain("sentiment TEXT DEFAULT 'neutral'");
|
|
29
|
+
expect(schema).toContain('related_error_correlation TEXT');
|
|
30
|
+
expect(schema).toContain('user_contact_encrypted TEXT');
|
|
31
|
+
expect(schema).toContain('correlation_confidence REAL');
|
|
32
|
+
expect(schema).toContain('correlation_method TEXT');
|
|
33
|
+
expect(schema).toContain('UNIQUE(repo, issue_number)');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should have performance indexes', () => {
|
|
37
|
+
expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_category');
|
|
38
|
+
expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_sentiment');
|
|
39
|
+
expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_correlation');
|
|
40
|
+
expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_received_at');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should have auto-update trigger', () => {
|
|
44
|
+
expect(schema).toContain('CREATE TRIGGER IF NOT EXISTS feedback_events_updated_at');
|
|
45
|
+
expect(schema).toContain('AFTER UPDATE ON feedback_events');
|
|
46
|
+
expect(schema).toContain("UPDATE feedback_events SET updated_at = datetime('now')");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Insert Operations', () => {
|
|
51
|
+
it('should insert valid feedback event', async () => {
|
|
52
|
+
const result = await db
|
|
53
|
+
.prepare(
|
|
54
|
+
`
|
|
55
|
+
INSERT INTO feedback_events (
|
|
56
|
+
feedback_id, repo, issue_number, category,
|
|
57
|
+
related_error_correlation, correlation_confidence, correlation_method,
|
|
58
|
+
user_contact_encrypted
|
|
59
|
+
)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
61
|
+
`
|
|
62
|
+
)
|
|
63
|
+
.bind(
|
|
64
|
+
'delivery-123',
|
|
65
|
+
'convert-my-file',
|
|
66
|
+
42,
|
|
67
|
+
'feature-request',
|
|
68
|
+
'corr-xyz',
|
|
69
|
+
0.85,
|
|
70
|
+
'rule-based',
|
|
71
|
+
'encrypted-email'
|
|
72
|
+
)
|
|
73
|
+
.run();
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
|
|
77
|
+
expect(insert).toBeTruthy();
|
|
78
|
+
expect(insert?.params).toHaveLength(8);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should insert minimal feedback event (no correlation)', async () => {
|
|
82
|
+
const result = await db
|
|
83
|
+
.prepare(
|
|
84
|
+
`
|
|
85
|
+
INSERT INTO feedback_events (
|
|
86
|
+
feedback_id, repo, issue_number, category
|
|
87
|
+
)
|
|
88
|
+
VALUES (?, ?, ?, ?)
|
|
89
|
+
`
|
|
90
|
+
)
|
|
91
|
+
.bind('delivery-456', 'notebridge', 10, 'bug-report')
|
|
92
|
+
.run();
|
|
93
|
+
|
|
94
|
+
expect(result.success).toBe(true);
|
|
95
|
+
const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
|
|
96
|
+
expect(insert).toBeTruthy();
|
|
97
|
+
expect(insert?.params).toEqual(['delivery-456', 'notebridge', 10, 'bug-report']);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Upsert Operations', () => {
|
|
102
|
+
it('should perform upsert on conflict', async () => {
|
|
103
|
+
const result = await db
|
|
104
|
+
.prepare(
|
|
105
|
+
`
|
|
106
|
+
INSERT INTO feedback_events (
|
|
107
|
+
feedback_id, repo, issue_number, category,
|
|
108
|
+
related_error_correlation, correlation_confidence, correlation_method
|
|
109
|
+
)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
111
|
+
ON CONFLICT(repo, issue_number) DO UPDATE SET
|
|
112
|
+
category = excluded.category,
|
|
113
|
+
related_error_correlation = excluded.related_error_correlation,
|
|
114
|
+
correlation_confidence = excluded.correlation_confidence,
|
|
115
|
+
correlation_method = excluded.correlation_method
|
|
116
|
+
`
|
|
117
|
+
)
|
|
118
|
+
.bind('delivery-789', 'palette-kit', 5, 'ux-improvement', 'corr-abc', 0.95, 'manual')
|
|
119
|
+
.run();
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(true);
|
|
122
|
+
const upsert = db.statements.find((stmt) => stmt.sql.includes('ON CONFLICT'));
|
|
123
|
+
expect(upsert).toBeTruthy();
|
|
124
|
+
expect(upsert?.sql).toContain('DO UPDATE SET');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Query Operations', () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
// Queue mock results for queries
|
|
131
|
+
db.queueRunResult({
|
|
132
|
+
success: true,
|
|
133
|
+
results: [
|
|
134
|
+
{
|
|
135
|
+
feedbackId: 'delivery-123',
|
|
136
|
+
repo: 'convert-my-file',
|
|
137
|
+
issueNumber: 42,
|
|
138
|
+
category: 'feature-request',
|
|
139
|
+
relatedErrorCorrelation: 'corr-xyz',
|
|
140
|
+
correlationConfidence: 0.85,
|
|
141
|
+
correlationMethod: 'rule-based',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should query by category', async () => {
|
|
148
|
+
const result = await db
|
|
149
|
+
.prepare(
|
|
150
|
+
`
|
|
151
|
+
SELECT
|
|
152
|
+
feedback_id as feedbackId,
|
|
153
|
+
repo,
|
|
154
|
+
issue_number as issueNumber,
|
|
155
|
+
category,
|
|
156
|
+
related_error_correlation as relatedErrorCorrelation,
|
|
157
|
+
correlation_confidence as correlationConfidence,
|
|
158
|
+
correlation_method as correlationMethod
|
|
159
|
+
FROM feedback_events
|
|
160
|
+
WHERE category = ?
|
|
161
|
+
ORDER BY received_at DESC
|
|
162
|
+
LIMIT ?
|
|
163
|
+
`
|
|
164
|
+
)
|
|
165
|
+
.bind('feature-request', 20)
|
|
166
|
+
.run();
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(true);
|
|
169
|
+
const query = db.statements.find((stmt) => stmt.sql.includes('WHERE category = ?'));
|
|
170
|
+
expect(query).toBeTruthy();
|
|
171
|
+
expect(query?.params).toEqual(['feature-request', 20]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should query correlated feedback', async () => {
|
|
175
|
+
const result = await db
|
|
176
|
+
.prepare(
|
|
177
|
+
`
|
|
178
|
+
SELECT *
|
|
179
|
+
FROM feedback_events
|
|
180
|
+
WHERE related_error_correlation IS NOT NULL
|
|
181
|
+
ORDER BY correlation_confidence DESC
|
|
182
|
+
`
|
|
183
|
+
)
|
|
184
|
+
.run();
|
|
185
|
+
|
|
186
|
+
expect(result.success).toBe(true);
|
|
187
|
+
const query = db.statements.find((stmt) =>
|
|
188
|
+
stmt.sql.includes('related_error_correlation IS NOT NULL')
|
|
189
|
+
);
|
|
190
|
+
expect(query).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should query by repo with time range', async () => {
|
|
194
|
+
const result = await db
|
|
195
|
+
.prepare(
|
|
196
|
+
`
|
|
197
|
+
SELECT *
|
|
198
|
+
FROM feedback_events
|
|
199
|
+
WHERE repo = ? AND received_at >= datetime('now', '-30 days')
|
|
200
|
+
ORDER BY received_at DESC
|
|
201
|
+
`
|
|
202
|
+
)
|
|
203
|
+
.bind('notebridge')
|
|
204
|
+
.run();
|
|
205
|
+
|
|
206
|
+
expect(result.success).toBe(true);
|
|
207
|
+
const query = db.statements.find((stmt) => stmt.sql.includes("datetime('now', '-30 days')"));
|
|
208
|
+
expect(query).toBeTruthy();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('Constraint Validation', () => {
|
|
213
|
+
it('should enforce NOT NULL on repo', () => {
|
|
214
|
+
// We're testing that the constraint is defined in the schema
|
|
215
|
+
expect(schema).toContain('repo TEXT NOT NULL');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should enforce NOT NULL on issue_number', () => {
|
|
219
|
+
expect(schema).toContain('issue_number INTEGER NOT NULL');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should enforce UNIQUE constraint on (repo, issue_number)', () => {
|
|
223
|
+
expect(schema).toContain('UNIQUE(repo, issue_number)');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('Default Values', () => {
|
|
228
|
+
it('should have default sentiment as neutral', () => {
|
|
229
|
+
expect(schema).toContain("sentiment TEXT DEFAULT 'neutral'");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should have default received_at timestamp', () => {
|
|
233
|
+
expect(schema).toContain("received_at TEXT DEFAULT (datetime('now'))");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should have default updated_at timestamp', () => {
|
|
237
|
+
expect(schema).toContain("updated_at TEXT DEFAULT (datetime('now'))");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Correlation Data', () => {
|
|
242
|
+
it('should store correlation confidence as REAL', () => {
|
|
243
|
+
expect(schema).toContain('correlation_confidence REAL');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should accept null correlation', async () => {
|
|
247
|
+
const result = await db
|
|
248
|
+
.prepare(
|
|
249
|
+
`
|
|
250
|
+
INSERT INTO feedback_events (
|
|
251
|
+
feedback_id, repo, issue_number, category,
|
|
252
|
+
related_error_correlation
|
|
253
|
+
)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?)
|
|
255
|
+
`
|
|
256
|
+
)
|
|
257
|
+
.bind('delivery-999', 'palette-kit', 99, 'other', null)
|
|
258
|
+
.run();
|
|
259
|
+
|
|
260
|
+
expect(result.success).toBe(true);
|
|
261
|
+
const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
|
|
262
|
+
expect(insert?.params[4]).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should store correlation method', async () => {
|
|
266
|
+
const result = await db
|
|
267
|
+
.prepare(
|
|
268
|
+
`
|
|
269
|
+
INSERT INTO feedback_events (
|
|
270
|
+
feedback_id, repo, issue_number, category, correlation_method
|
|
271
|
+
)
|
|
272
|
+
VALUES (?, ?, ?, ?, ?)
|
|
273
|
+
`
|
|
274
|
+
)
|
|
275
|
+
.bind('delivery-111', 'convert-my-file', 11, 'bug-report', 'ml')
|
|
276
|
+
.run();
|
|
277
|
+
|
|
278
|
+
expect(result.success).toBe(true);
|
|
279
|
+
const insert = db.statements.find((stmt) => stmt.sql.includes('correlation_method'));
|
|
280
|
+
expect(insert?.params).toContain('ml');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('GDPR Compliance', () => {
|
|
285
|
+
it('should allow null user_contact_encrypted', async () => {
|
|
286
|
+
const result = await db
|
|
287
|
+
.prepare(
|
|
288
|
+
`
|
|
289
|
+
INSERT INTO feedback_events (
|
|
290
|
+
feedback_id, repo, issue_number, category, user_contact_encrypted
|
|
291
|
+
)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?)
|
|
293
|
+
`
|
|
294
|
+
)
|
|
295
|
+
.bind('delivery-222', 'notebridge', 22, 'feature-request', null)
|
|
296
|
+
.run();
|
|
297
|
+
|
|
298
|
+
expect(result.success).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should store encrypted user contact', async () => {
|
|
302
|
+
const encryptedEmail = 'v1.aes-gcm.base64encodeddata';
|
|
303
|
+
const result = await db
|
|
304
|
+
.prepare(
|
|
305
|
+
`
|
|
306
|
+
INSERT INTO feedback_events (
|
|
307
|
+
feedback_id, repo, issue_number, category, user_contact_encrypted
|
|
308
|
+
)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?)
|
|
310
|
+
`
|
|
311
|
+
)
|
|
312
|
+
.bind('delivery-333', 'palette-kit', 33, 'bug-report', encryptedEmail)
|
|
313
|
+
.run();
|
|
314
|
+
|
|
315
|
+
expect(result.success).toBe(true);
|
|
316
|
+
const insert = db.statements.find((stmt) => stmt.sql.includes('user_contact_encrypted'));
|
|
317
|
+
expect(insert?.params).toContain(encryptedEmail);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('KV Cache Integration', () => {
|
|
322
|
+
it('should cache feedback event after insert', async () => {
|
|
323
|
+
const feedbackData = {
|
|
324
|
+
feedbackId: 'delivery-444',
|
|
325
|
+
repo: 'convert-my-file',
|
|
326
|
+
issueNumber: 44,
|
|
327
|
+
category: 'feature-request',
|
|
328
|
+
relatedErrorCorrelation: null,
|
|
329
|
+
correlationConfidence: 0,
|
|
330
|
+
correlationMethod: 'rule-based',
|
|
331
|
+
userContactEncrypted: null,
|
|
332
|
+
receivedAt: new Date().toISOString(),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const cacheKey = `feedback:${feedbackData.repo}:${feedbackData.issueNumber}`;
|
|
336
|
+
await kv.put(cacheKey, JSON.stringify(feedbackData), {
|
|
337
|
+
expirationTtl: 900,
|
|
338
|
+
} as any);
|
|
339
|
+
|
|
340
|
+
expect(kv.store.get(cacheKey)).toBeTruthy();
|
|
341
|
+
const cached = JSON.parse(kv.store.get(cacheKey)!);
|
|
342
|
+
expect(cached.feedbackId).toBe('delivery-444');
|
|
343
|
+
expect(cached.category).toBe('feature-request');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should invalidate recent list cache on new event', async () => {
|
|
347
|
+
const repo = 'notebridge';
|
|
348
|
+
const cacheKey = `feedback:recent:${repo}`;
|
|
349
|
+
|
|
350
|
+
// Set cache
|
|
351
|
+
await kv.put(cacheKey, JSON.stringify([]), {
|
|
352
|
+
expirationTtl: 900,
|
|
353
|
+
} as any);
|
|
354
|
+
|
|
355
|
+
// Invalidate on new event
|
|
356
|
+
await kv.delete(cacheKey);
|
|
357
|
+
|
|
358
|
+
expect(kv.store.has(cacheKey)).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
5
|
+
import {
|
|
6
|
+
archiveEvent,
|
|
7
|
+
getArchiveStats,
|
|
8
|
+
listArchivedEvents,
|
|
9
|
+
retrieveEvent,
|
|
10
|
+
type PlatformEvent,
|
|
11
|
+
} from '../../storage/r2/archive';
|
|
12
|
+
|
|
13
|
+
describe('R2 archive helpers', () => {
|
|
14
|
+
let mockR2: {
|
|
15
|
+
put: ReturnType<typeof vi.fn>;
|
|
16
|
+
get: ReturnType<typeof vi.fn>;
|
|
17
|
+
list: ReturnType<typeof vi.fn>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockR2 = {
|
|
22
|
+
put: vi.fn(),
|
|
23
|
+
get: vi.fn(),
|
|
24
|
+
list: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('archives events using gzip compression and metadata', async () => {
|
|
29
|
+
const event: PlatformEvent = {
|
|
30
|
+
id: 'evt-123',
|
|
31
|
+
source: 'cloakpipe',
|
|
32
|
+
type: 'error_report',
|
|
33
|
+
timestamp: '2025-10-25T10:00:00Z',
|
|
34
|
+
data: { foo: 'bar' },
|
|
35
|
+
metadata: { schema_version: '1.0.0' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const key = await archiveEvent(event, mockR2 as any);
|
|
39
|
+
|
|
40
|
+
expect(key).toBe('2025/10/25/cloakpipe/evt-123.json.gz');
|
|
41
|
+
expect(mockR2.put).toHaveBeenCalledTimes(1);
|
|
42
|
+
|
|
43
|
+
const [, body, options] = mockR2.put.mock.calls[0];
|
|
44
|
+
expect(options).toMatchObject({
|
|
45
|
+
httpMetadata: { contentType: 'application/json', contentEncoding: 'gzip' },
|
|
46
|
+
customMetadata: {
|
|
47
|
+
source: 'cloakpipe',
|
|
48
|
+
type: 'error_report',
|
|
49
|
+
timestamp: '2025-10-25T10:00:00Z',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const buffer = Buffer.from(body as ArrayBuffer);
|
|
54
|
+
const decompressed = JSON.parse(gunzipSync(buffer).toString('utf-8'));
|
|
55
|
+
expect(decompressed).toMatchObject({
|
|
56
|
+
id: 'evt-123',
|
|
57
|
+
source: 'cloakpipe',
|
|
58
|
+
type: 'error_report',
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('retrieves archived events and decompresses payload', async () => {
|
|
63
|
+
const event: PlatformEvent = {
|
|
64
|
+
id: 'evt-999',
|
|
65
|
+
source: 'homeostat',
|
|
66
|
+
type: 'autofix_result',
|
|
67
|
+
timestamp: '2025-10-26T12:34:56Z',
|
|
68
|
+
data: { status: 'ok' },
|
|
69
|
+
metadata: { schema_version: '1.0.0' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const compressed = gzipSync(Buffer.from(JSON.stringify(event), 'utf-8'));
|
|
73
|
+
mockR2.get.mockResolvedValue({
|
|
74
|
+
arrayBuffer: async () =>
|
|
75
|
+
compressed.buffer.slice(
|
|
76
|
+
compressed.byteOffset,
|
|
77
|
+
compressed.byteOffset + compressed.byteLength
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const retrieved = await retrieveEvent('2025/10/26/homeostat/evt-999.json.gz', mockR2 as any);
|
|
82
|
+
|
|
83
|
+
expect(retrieved).toEqual(event);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('lists archived events across date range prefixes', async () => {
|
|
87
|
+
mockR2.list
|
|
88
|
+
.mockResolvedValueOnce({ objects: [{ key: '2025/10/24/cloakpipe/a.json.gz' }] })
|
|
89
|
+
.mockResolvedValueOnce({ objects: [{ key: '2025/10/25/cloakpipe/b.json.gz' }] });
|
|
90
|
+
|
|
91
|
+
const keys = await listArchivedEvents('cloakpipe', '2025-10-24', '2025-10-25', mockR2 as any);
|
|
92
|
+
|
|
93
|
+
expect(keys).toEqual(['2025/10/24/cloakpipe/a.json.gz', '2025/10/25/cloakpipe/b.json.gz']);
|
|
94
|
+
expect(mockR2.list).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('summarises archive statistics', async () => {
|
|
98
|
+
mockR2.list.mockResolvedValue({
|
|
99
|
+
objects: [
|
|
100
|
+
{ key: 'foo', size: 10 },
|
|
101
|
+
{ key: 'bar', size: 20 },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const stats = await getArchiveStats(mockR2 as any);
|
|
106
|
+
expect(stats).toEqual({ count: 2, totalSizeBytes: 30, compressionRatio: null });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Dependabot Auto-Merge
|
|
2
|
+
# Automatically merges patch/minor dependency updates when CI passes
|
|
3
|
+
#
|
|
4
|
+
# Reference: GitHub Security Rollout - littlebearapps
|
|
5
|
+
|
|
6
|
+
name: Dependabot Auto-Merge
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
pull_request:
|
|
10
|
+
types: [opened, synchronize, reopened]
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: write
|
|
14
|
+
pull-requests: write
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
dependabot-automerge:
|
|
18
|
+
name: Auto-merge Dependabot
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
if: github.actor == 'dependabot[bot]'
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- name: Dependabot metadata
|
|
24
|
+
id: metadata
|
|
25
|
+
uses: dependabot/fetch-metadata@v2.4.0
|
|
26
|
+
with:
|
|
27
|
+
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
|
28
|
+
|
|
29
|
+
- name: Enable auto-merge for patch/minor updates
|
|
30
|
+
if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'
|
|
31
|
+
run: gh pr merge --auto --squash "$PR_URL"
|
|
32
|
+
env:
|
|
33
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
34
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
35
|
+
|
|
36
|
+
- name: Log major updates (manual review required)
|
|
37
|
+
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
|
|
38
|
+
run: |
|
|
39
|
+
echo "::notice::Major version update detected - manual review required"
|
|
40
|
+
echo "Package: ${{ steps.metadata.outputs.dependency-names }}"
|
|
41
|
+
echo "Update type: ${{ steps.metadata.outputs.update-type }}"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Validate Controls
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
paths:
|
|
6
|
+
- 'monitoring/controls.json'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
validate:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v6
|
|
13
|
+
|
|
14
|
+
- uses: actions/setup-node@v6
|
|
15
|
+
with:
|
|
16
|
+
node-version: '20'
|
|
17
|
+
|
|
18
|
+
- name: Install dependencies
|
|
19
|
+
run: npm ci
|
|
20
|
+
|
|
21
|
+
- name: Validate controls.json
|
|
22
|
+
run: npm run validate:controls
|
|
23
|
+
|
|
24
|
+
- name: Require platform approval note
|
|
25
|
+
if: success()
|
|
26
|
+
run: |
|
|
27
|
+
echo '⚠️ Circuit breaker changes require platform team approval'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Breadcrumbs.astro
|
|
4
|
+
* Navigation breadcrumbs for deep pages
|
|
5
|
+
* Truncates middle segments on mobile (shows first and last)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface BreadcrumbItem {
|
|
9
|
+
label: string;
|
|
10
|
+
href?: string; // Optional - current page won't have href
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
items: BreadcrumbItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { items } = Astro.props;
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<nav aria-label="Breadcrumb" class="mb-4">
|
|
21
|
+
<ol class="flex items-center text-sm">
|
|
22
|
+
{
|
|
23
|
+
items.map((item, index) => {
|
|
24
|
+
const isLast = index === items.length - 1;
|
|
25
|
+
const isFirst = index === 0;
|
|
26
|
+
const isMiddle = !isFirst && !isLast;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<li class={`flex items-center ${isMiddle ? 'hidden sm:flex' : ''}`}>
|
|
30
|
+
{/* Separator (not for first item) */}
|
|
31
|
+
{index > 0 && (
|
|
32
|
+
<svg
|
|
33
|
+
class={`w-4 h-4 mx-2 text-gray-400 dark:text-gray-500 flex-shrink-0 ${isMiddle ? 'hidden sm:block' : ''}`}
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
>
|
|
38
|
+
<path
|
|
39
|
+
stroke-linecap="round"
|
|
40
|
+
stroke-linejoin="round"
|
|
41
|
+
stroke-width="2"
|
|
42
|
+
d="M9 5l7 7-7 7"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
)}
|
|
46
|
+
|
|
47
|
+
{/* Mobile ellipsis (shown between first and last on mobile when there are middle items) */}
|
|
48
|
+
{isLast && items.length > 2 && (
|
|
49
|
+
<span class="sm:hidden flex items-center">
|
|
50
|
+
<svg
|
|
51
|
+
class="w-4 h-4 mx-2 text-gray-400 dark:text-gray-500"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
>
|
|
56
|
+
<path
|
|
57
|
+
stroke-linecap="round"
|
|
58
|
+
stroke-linejoin="round"
|
|
59
|
+
stroke-width="2"
|
|
60
|
+
d="M9 5l7 7-7 7"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
<span class="text-gray-400 dark:text-gray-500 mr-2">...</span>
|
|
64
|
+
<svg
|
|
65
|
+
class="w-4 h-4 mr-2 text-gray-400 dark:text-gray-500"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
viewBox="0 0 24 24"
|
|
69
|
+
>
|
|
70
|
+
<path
|
|
71
|
+
stroke-linecap="round"
|
|
72
|
+
stroke-linejoin="round"
|
|
73
|
+
stroke-width="2"
|
|
74
|
+
d="M9 5l7 7-7 7"
|
|
75
|
+
/>
|
|
76
|
+
</svg>
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Breadcrumb item */}
|
|
81
|
+
{item.href && !isLast ? (
|
|
82
|
+
<a
|
|
83
|
+
href={item.href}
|
|
84
|
+
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
85
|
+
>
|
|
86
|
+
{item.label}
|
|
87
|
+
</a>
|
|
88
|
+
) : (
|
|
89
|
+
<span
|
|
90
|
+
class={`${isLast ? 'text-gray-900 dark:text-white font-medium' : 'text-gray-500 dark:text-gray-400'}`}
|
|
91
|
+
aria-current={isLast ? 'page' : undefined}
|
|
92
|
+
>
|
|
93
|
+
{item.label}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</li>
|
|
97
|
+
);
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
</ol>
|
|
101
|
+
</nav>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EmptyState.astro
|
|
4
|
+
* Empty state placeholder with icon, title, description, and optional CTA
|
|
5
|
+
* Use when there's no data to display
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
icon?: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { icon = '📭', title, description, compact = false } = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<div class={`flex flex-col items-center justify-center text-center ${compact ? 'py-8' : 'py-16'}`}>
|
|
19
|
+
{/* Icon */}
|
|
20
|
+
<div class={`${compact ? 'text-3xl mb-3' : 'text-5xl mb-4'}`} role="img" aria-hidden="true">
|
|
21
|
+
{icon}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
{/* Title */}
|
|
25
|
+
<h3
|
|
26
|
+
class={`font-semibold text-gray-900 dark:text-white ${compact ? 'text-base mb-1' : 'text-lg mb-2'}`}
|
|
27
|
+
>
|
|
28
|
+
{title}
|
|
29
|
+
</h3>
|
|
30
|
+
|
|
31
|
+
{/* Description */}
|
|
32
|
+
{
|
|
33
|
+
description && (
|
|
34
|
+
<p
|
|
35
|
+
class={`text-gray-500 dark:text-gray-400 max-w-md ${compact ? 'text-sm mb-3' : 'text-base mb-6'}`}
|
|
36
|
+
>
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
{/* CTA Slot */}
|
|
43
|
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
|
44
|
+
<slot name="cta" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|