trickle-backend 0.1.63 → 0.1.64

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.
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare function runCloudMigrations(db: Database.Database): void;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCloudMigrations = runCloudMigrations;
4
+ function runCloudMigrations(db) {
5
+ db.exec(`
6
+ -- Projects table: each project is an isolated workspace
7
+ CREATE TABLE IF NOT EXISTS projects (
8
+ id TEXT PRIMARY KEY,
9
+ name TEXT NOT NULL,
10
+ owner_key_id TEXT NOT NULL,
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
12
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
13
+ settings TEXT DEFAULT '{}'
14
+ );
15
+
16
+ -- API keys for authentication
17
+ CREATE TABLE IF NOT EXISTS api_keys (
18
+ id TEXT PRIMARY KEY,
19
+ key_hash TEXT NOT NULL UNIQUE,
20
+ key_prefix TEXT NOT NULL,
21
+ name TEXT NOT NULL DEFAULT 'default',
22
+ owner_email TEXT,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
24
+ last_used_at TEXT,
25
+ revoked INTEGER NOT NULL DEFAULT 0
26
+ );
27
+
28
+ -- Project data: stores all JSONL/JSON files per project
29
+ CREATE TABLE IF NOT EXISTS project_data (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ project_id TEXT NOT NULL REFERENCES projects(id),
32
+ filename TEXT NOT NULL,
33
+ content TEXT NOT NULL,
34
+ size_bytes INTEGER NOT NULL DEFAULT 0,
35
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now')),
36
+ UNIQUE(project_id, filename)
37
+ );
38
+
39
+ -- Push history for audit trail
40
+ CREATE TABLE IF NOT EXISTS push_history (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ project_id TEXT NOT NULL REFERENCES projects(id),
43
+ key_id TEXT NOT NULL,
44
+ file_count INTEGER NOT NULL,
45
+ total_bytes INTEGER NOT NULL,
46
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ );
48
+
49
+ -- Shared dashboards (public read-only links)
50
+ CREATE TABLE IF NOT EXISTS share_links (
51
+ id TEXT PRIMARY KEY,
52
+ project_id TEXT NOT NULL REFERENCES projects(id),
53
+ created_by TEXT NOT NULL,
54
+ expires_at TEXT,
55
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
59
+ CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
60
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
61
+ `);
62
+ }
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const server_1 = require("./server");
4
4
  const connection_1 = require("./db/connection");
5
5
  const migrations_1 = require("./db/migrations");
6
+ const cloud_migrations_1 = require("./db/cloud-migrations");
6
7
  (0, migrations_1.runMigrations)(connection_1.db);
8
+ (0, cloud_migrations_1.runCloudMigrations)(connection_1.db);
7
9
  const PORT = parseInt(process.env.PORT || "4888", 10);
8
10
  server_1.app.listen(PORT, () => {
9
11
  console.log(`[trickle] Backend listening on http://localhost:${PORT}`);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cloud API routes — /api/v1/*
3
+ *
4
+ * Provides multi-tenant cloud observability:
5
+ * POST /api/v1/push — Upload .trickle/ data for a project
6
+ * GET /api/v1/pull — Download project data
7
+ * GET /api/v1/projects — List projects for authenticated user
8
+ * POST /api/v1/projects — Create a new project
9
+ * POST /api/v1/keys — Generate a new API key
10
+ * POST /api/v1/share — Create a shareable dashboard link
11
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
12
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
13
+ */
14
+ declare const router: import("express-serve-static-core").Router;
15
+ export default router;
@@ -0,0 +1,434 @@
1
+ "use strict";
2
+ /**
3
+ * Cloud API routes — /api/v1/*
4
+ *
5
+ * Provides multi-tenant cloud observability:
6
+ * POST /api/v1/push — Upload .trickle/ data for a project
7
+ * GET /api/v1/pull — Download project data
8
+ * GET /api/v1/projects — List projects for authenticated user
9
+ * POST /api/v1/projects — Create a new project
10
+ * POST /api/v1/keys — Generate a new API key
11
+ * POST /api/v1/share — Create a shareable dashboard link
12
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
13
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ const express_1 = require("express");
20
+ const crypto_1 = __importDefault(require("crypto"));
21
+ const connection_1 = require("../db/connection");
22
+ const router = (0, express_1.Router)();
23
+ // ── Helpers ──
24
+ function generateId() {
25
+ return crypto_1.default.randomBytes(12).toString("hex");
26
+ }
27
+ function generateApiKey() {
28
+ const key = `tk_${crypto_1.default.randomBytes(24).toString("hex")}`;
29
+ const hash = crypto_1.default.createHash("sha256").update(key).digest("hex");
30
+ const prefix = key.slice(0, 7);
31
+ return { key, hash, prefix };
32
+ }
33
+ function hashKey(key) {
34
+ return crypto_1.default.createHash("sha256").update(key).digest("hex");
35
+ }
36
+ function requireAuth(req, res, next) {
37
+ const authHeader = req.headers.authorization;
38
+ if (!authHeader?.startsWith("Bearer ")) {
39
+ res.status(401).json({ error: "Missing or invalid Authorization header. Use: Bearer <api-key>" });
40
+ return;
41
+ }
42
+ const token = authHeader.slice(7);
43
+ const tokenHash = hashKey(token);
44
+ const key = connection_1.db.prepare("SELECT id, name, revoked FROM api_keys WHERE key_hash = ?").get(tokenHash);
45
+ if (!key) {
46
+ res.status(401).json({ error: "Invalid API key" });
47
+ return;
48
+ }
49
+ if (key.revoked) {
50
+ res.status(403).json({ error: "API key has been revoked" });
51
+ return;
52
+ }
53
+ // Update last_used_at
54
+ connection_1.db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(key.id);
55
+ req.keyId = key.id;
56
+ req.keyName = key.name;
57
+ next();
58
+ }
59
+ // ── POST /api/v1/keys — Generate a new API key ──
60
+ router.post("/keys", (req, res) => {
61
+ const { name, email } = req.body || {};
62
+ const { key, hash, prefix } = generateApiKey();
63
+ const id = generateId();
64
+ connection_1.db.prepare("INSERT INTO api_keys (id, key_hash, key_prefix, name, owner_email) VALUES (?, ?, ?, ?, ?)").run(id, hash, prefix, name || "default", email || null);
65
+ res.status(201).json({
66
+ id,
67
+ key, // Only returned once — user must save it
68
+ prefix,
69
+ name: name || "default",
70
+ message: "Save this key — it cannot be retrieved later.",
71
+ });
72
+ });
73
+ // ── POST /api/v1/push — Upload project data ──
74
+ router.post("/push", requireAuth, (req, res) => {
75
+ const { project, files, timestamp } = req.body;
76
+ if (!project || typeof project !== "string") {
77
+ res.status(400).json({ error: "project name required" });
78
+ return;
79
+ }
80
+ if (!files || typeof files !== "object") {
81
+ res.status(400).json({ error: "files object required" });
82
+ return;
83
+ }
84
+ const projectId = `${req.keyId}:${project}`;
85
+ // Upsert project
86
+ connection_1.db.prepare(`
87
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
88
+ VALUES (?, ?, ?, datetime('now'))
89
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
90
+ `).run(projectId, project, req.keyId);
91
+ // Upsert each file
92
+ const upsertFile = connection_1.db.prepare(`
93
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
94
+ VALUES (?, ?, ?, ?, datetime('now'))
95
+ ON CONFLICT(project_id, filename) DO UPDATE SET
96
+ content = excluded.content,
97
+ size_bytes = excluded.size_bytes,
98
+ pushed_at = datetime('now')
99
+ `);
100
+ let totalBytes = 0;
101
+ let fileCount = 0;
102
+ const insertMany = connection_1.db.transaction(() => {
103
+ for (const [filename, content] of Object.entries(files)) {
104
+ if (typeof content !== "string")
105
+ continue;
106
+ const bytes = Buffer.byteLength(content, "utf-8");
107
+ upsertFile.run(projectId, filename, content, bytes);
108
+ totalBytes += bytes;
109
+ fileCount++;
110
+ }
111
+ });
112
+ insertMany();
113
+ // Record push history
114
+ connection_1.db.prepare("INSERT INTO push_history (project_id, key_id, file_count, total_bytes) VALUES (?, ?, ?, ?)").run(projectId, req.keyId, fileCount, totalBytes);
115
+ const dashboardUrl = `${req.protocol}://${req.get("host")}/api/v1/dashboard/${encodeURIComponent(projectId)}`;
116
+ res.json({
117
+ ok: true,
118
+ project: projectId,
119
+ files: fileCount,
120
+ bytes: totalBytes,
121
+ url: dashboardUrl,
122
+ });
123
+ });
124
+ // ── GET /api/v1/pull — Download project data ──
125
+ router.get("/pull", requireAuth, (req, res) => {
126
+ const project = req.query.project;
127
+ if (!project) {
128
+ res.status(400).json({ error: "project query parameter required" });
129
+ return;
130
+ }
131
+ const projectId = `${req.keyId}:${project}`;
132
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
133
+ if (rows.length === 0) {
134
+ res.status(404).json({ error: "No data found for this project" });
135
+ return;
136
+ }
137
+ const files = {};
138
+ for (const row of rows) {
139
+ files[row.filename] = row.content;
140
+ }
141
+ res.json({ project, files, fileCount: rows.length });
142
+ });
143
+ // ── GET /api/v1/projects — List projects ──
144
+ router.get("/projects", requireAuth, (req, res) => {
145
+ const rows = connection_1.db.prepare(`
146
+ SELECT p.id, p.name, p.created_at, p.updated_at,
147
+ (SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
148
+ (SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
149
+ FROM projects p
150
+ WHERE p.owner_key_id = ?
151
+ ORDER BY p.updated_at DESC
152
+ `).all(req.keyId);
153
+ res.json({
154
+ projects: rows.map((r) => ({
155
+ id: r.id,
156
+ name: r.name,
157
+ files: r.file_count || 0,
158
+ size: r.total_bytes || 0,
159
+ createdAt: r.created_at,
160
+ updatedAt: r.updated_at,
161
+ })),
162
+ });
163
+ });
164
+ // ── POST /api/v1/projects — Create project ──
165
+ router.post("/projects", requireAuth, (req, res) => {
166
+ const { name } = req.body;
167
+ if (!name) {
168
+ res.status(400).json({ error: "name required" });
169
+ return;
170
+ }
171
+ const projectId = `${req.keyId}:${name}`;
172
+ connection_1.db.prepare(`
173
+ INSERT INTO projects (id, name, owner_key_id)
174
+ VALUES (?, ?, ?)
175
+ ON CONFLICT(id) DO NOTHING
176
+ `).run(projectId, name, req.keyId);
177
+ res.status(201).json({ id: projectId, name });
178
+ });
179
+ // ── POST /api/v1/share — Create shareable link ──
180
+ router.post("/share", requireAuth, (req, res) => {
181
+ const { project, expiresInHours } = req.body;
182
+ if (!project) {
183
+ res.status(400).json({ error: "project name required" });
184
+ return;
185
+ }
186
+ const projectId = `${req.keyId}:${project}`;
187
+ // Verify project exists
188
+ const proj = connection_1.db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
189
+ if (!proj) {
190
+ res.status(404).json({ error: "Project not found" });
191
+ return;
192
+ }
193
+ const shareId = generateId();
194
+ const expiresAt = expiresInHours
195
+ ? new Date(Date.now() + expiresInHours * 3600000).toISOString()
196
+ : null;
197
+ connection_1.db.prepare("INSERT INTO share_links (id, project_id, created_by, expires_at) VALUES (?, ?, ?, ?)").run(shareId, projectId, req.keyId, expiresAt);
198
+ const shareUrl = `${req.protocol}://${req.get("host")}/api/v1/shared/${shareId}`;
199
+ res.status(201).json({
200
+ shareId,
201
+ url: shareUrl,
202
+ expiresAt,
203
+ });
204
+ });
205
+ // ── GET /api/v1/shared/:id — View shared data (no auth) ──
206
+ router.get("/shared/:id", (req, res) => {
207
+ const link = connection_1.db.prepare(`
208
+ SELECT sl.project_id, sl.expires_at, p.name as project_name
209
+ FROM share_links sl
210
+ JOIN projects p ON p.id = sl.project_id
211
+ WHERE sl.id = ?
212
+ `).get(req.params.id);
213
+ if (!link) {
214
+ res.status(404).json({ error: "Share link not found" });
215
+ return;
216
+ }
217
+ if (link.expires_at && new Date(link.expires_at) < new Date()) {
218
+ res.status(410).json({ error: "Share link has expired" });
219
+ return;
220
+ }
221
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(link.project_id);
222
+ const files = {};
223
+ for (const row of rows) {
224
+ files[row.filename] = row.content;
225
+ }
226
+ // If request accepts HTML, serve dashboard
227
+ if (req.accepts("html")) {
228
+ res.send(generateDashboardHtml(link.project_name, files));
229
+ return;
230
+ }
231
+ res.json({ project: link.project_name, files, fileCount: rows.length });
232
+ });
233
+ // ── GET /api/v1/dashboard/:projectId — Authenticated dashboard ──
234
+ router.get("/dashboard/:projectId", requireAuth, (req, res) => {
235
+ const projectId = decodeURIComponent(req.params.projectId);
236
+ // Verify ownership
237
+ const proj = connection_1.db.prepare("SELECT name FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId);
238
+ if (!proj) {
239
+ res.status(404).json({ error: "Project not found" });
240
+ return;
241
+ }
242
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
243
+ const files = {};
244
+ for (const row of rows) {
245
+ files[row.filename] = row.content;
246
+ }
247
+ if (req.accepts("html")) {
248
+ res.send(generateDashboardHtml(proj.name, files));
249
+ return;
250
+ }
251
+ res.json({ project: proj.name, files, fileCount: rows.length });
252
+ });
253
+ // ── Dashboard HTML generator ──
254
+ function generateDashboardHtml(projectName, files) {
255
+ // Parse data files
256
+ const parseJsonl = (content) => content.split("\n").filter(Boolean).map(l => { try {
257
+ return JSON.parse(l);
258
+ }
259
+ catch {
260
+ return null;
261
+ } }).filter(Boolean);
262
+ const observations = files["observations.jsonl"] ? parseJsonl(files["observations.jsonl"]) : [];
263
+ const variables = files["variables.jsonl"] ? parseJsonl(files["variables.jsonl"]) : [];
264
+ const calltrace = files["calltrace.jsonl"] ? parseJsonl(files["calltrace.jsonl"]) : [];
265
+ const queries = files["queries.jsonl"] ? parseJsonl(files["queries.jsonl"]) : [];
266
+ const errors = files["errors.jsonl"] ? parseJsonl(files["errors.jsonl"]) : [];
267
+ const alerts = files["alerts.jsonl"] ? parseJsonl(files["alerts.jsonl"]) : [];
268
+ const profile = files["profile.jsonl"] ? parseJsonl(files["profile.jsonl"]) : [];
269
+ let environment = {};
270
+ try {
271
+ if (files["environment.json"])
272
+ environment = JSON.parse(files["environment.json"]);
273
+ }
274
+ catch { }
275
+ const criticalAlerts = alerts.filter((a) => a.severity === "critical");
276
+ const warningAlerts = alerts.filter((a) => a.severity === "warning");
277
+ const endProfile = profile.find((p) => p.event === "end");
278
+ const status = criticalAlerts.length > 0 ? "critical" :
279
+ errors.length > 0 ? "error" :
280
+ warningAlerts.length > 0 ? "warning" : "healthy";
281
+ const statusColors = {
282
+ healthy: "#22c55e", warning: "#eab308", error: "#ef4444", critical: "#dc2626",
283
+ };
284
+ const slowFuncs = observations
285
+ .filter((o) => o.durationMs && o.durationMs > 10)
286
+ .sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0))
287
+ .slice(0, 10);
288
+ return `<!DOCTYPE html>
289
+ <html lang="en">
290
+ <head>
291
+ <meta charset="utf-8">
292
+ <meta name="viewport" content="width=device-width, initial-scale=1">
293
+ <title>${projectName} — trickle dashboard</title>
294
+ <style>
295
+ * { margin: 0; padding: 0; box-sizing: border-box; }
296
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e5e7eb; line-height: 1.5; }
297
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
298
+ h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
299
+ h2 { font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #9ca3af; }
300
+ .subtitle { color: #6b7280; font-size: 14px; margin-bottom: 24px; }
301
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
302
+ .card { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; }
303
+ .card-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
304
+ .card-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
305
+ .status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
306
+ table { width: 100%; border-collapse: collapse; }
307
+ th { text-align: left; padding: 8px 12px; font-size: 12px; color: #9ca3af; text-transform: uppercase; border-bottom: 1px solid #374151; }
308
+ td { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 14px; }
309
+ .mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
310
+ .bar { height: 8px; border-radius: 4px; background: #3b82f6; display: inline-block; min-width: 4px; }
311
+ .alert-critical { border-left: 3px solid #ef4444; }
312
+ .alert-warning { border-left: 3px solid #eab308; }
313
+ .error-card { background: #1f2937; border-left: 3px solid #ef4444; padding: 12px; margin-bottom: 8px; border-radius: 4px; }
314
+ .section { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; margin-bottom: 16px; }
315
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; background: #374151; color: #9ca3af; margin-right: 4px; }
316
+ footer { text-align: center; padding: 24px; color: #4b5563; font-size: 12px; }
317
+ a { color: #3b82f6; text-decoration: none; }
318
+ </style>
319
+ </head>
320
+ <body>
321
+ <div class="container">
322
+ <h1>${projectName}</h1>
323
+ <div class="subtitle">
324
+ <span class="status-badge" style="background: ${statusColors[status]}20; color: ${statusColors[status]}">${status.toUpperCase()}</span>
325
+ ${environment.node ? `<span class="tag">Node ${environment.node.version}</span>` : ""}
326
+ ${environment.python ? `<span class="tag">Python ${environment.python}</span>` : ""}
327
+ ${endProfile ? `<span class="tag">${Math.round((endProfile.rssKb || 0) / 1024)}MB RSS</span>` : ""}
328
+ </div>
329
+
330
+ <div class="grid">
331
+ <div class="card">
332
+ <div class="card-label">Functions</div>
333
+ <div class="card-value">${observations.length}</div>
334
+ </div>
335
+ <div class="card">
336
+ <div class="card-label">Variables</div>
337
+ <div class="card-value">${variables.length}</div>
338
+ </div>
339
+ <div class="card">
340
+ <div class="card-label">DB Queries</div>
341
+ <div class="card-value">${queries.length}</div>
342
+ </div>
343
+ <div class="card">
344
+ <div class="card-label">Call Trace</div>
345
+ <div class="card-value">${calltrace.length}</div>
346
+ </div>
347
+ <div class="card">
348
+ <div class="card-label">Errors</div>
349
+ <div class="card-value" style="color: ${errors.length > 0 ? "#ef4444" : "#22c55e"}">${errors.length}</div>
350
+ </div>
351
+ <div class="card">
352
+ <div class="card-label">Alerts</div>
353
+ <div class="card-value" style="color: ${criticalAlerts.length > 0 ? "#ef4444" : warningAlerts.length > 0 ? "#eab308" : "#22c55e"}">${alerts.length}</div>
354
+ </div>
355
+ </div>
356
+
357
+ ${alerts.length > 0 ? `
358
+ <h2>Alerts</h2>
359
+ <div class="section">
360
+ ${alerts.map((a) => `
361
+ <div class="card ${a.severity === "critical" ? "alert-critical" : "alert-warning"}" style="margin-bottom: 8px;">
362
+ <strong>${a.severity === "critical" ? "CRITICAL" : "WARNING"}</strong>: ${escapeHtml(a.message || "")}
363
+ ${a.suggestion ? `<div style="color: #9ca3af; font-size: 13px; margin-top: 4px;">Fix: ${escapeHtml(a.suggestion)}</div>` : ""}
364
+ </div>
365
+ `).join("")}
366
+ </div>` : ""}
367
+
368
+ ${errors.length > 0 ? `
369
+ <h2>Errors</h2>
370
+ <div class="section">
371
+ ${errors.slice(0, 10).map((e) => `
372
+ <div class="error-card">
373
+ <strong class="mono">${escapeHtml(e.type || "Error")}</strong>: ${escapeHtml((e.message || "").substring(0, 200))}
374
+ ${e.function ? `<div style="color: #6b7280; font-size: 12px;">in ${escapeHtml(e.function)}</div>` : ""}
375
+ </div>
376
+ `).join("")}
377
+ </div>` : ""}
378
+
379
+ ${slowFuncs.length > 0 ? `
380
+ <h2>Performance Hotspots</h2>
381
+ <div class="section">
382
+ <table>
383
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th><th></th></tr></thead>
384
+ <tbody>
385
+ ${slowFuncs.map((f) => {
386
+ const pct = Math.round((f.durationMs / slowFuncs[0].durationMs) * 100);
387
+ return `<tr>
388
+ <td class="mono">${escapeHtml(f.functionName || "?")}</td>
389
+ <td>${escapeHtml(f.module || "?")}</td>
390
+ <td>${f.durationMs?.toFixed(0)}ms</td>
391
+ <td><div class="bar" style="width: ${pct}%"></div></td>
392
+ </tr>`;
393
+ }).join("")}
394
+ </tbody>
395
+ </table>
396
+ </div>` : ""}
397
+
398
+ ${observations.length > 0 ? `
399
+ <h2>Observed Functions</h2>
400
+ <div class="section">
401
+ <table>
402
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th></tr></thead>
403
+ <tbody>
404
+ ${observations.slice(0, 30).map((o) => `<tr>
405
+ <td class="mono">${escapeHtml(o.functionName || "?")}</td>
406
+ <td>${escapeHtml(o.module || "?")}</td>
407
+ <td>${o.durationMs ? o.durationMs.toFixed(0) + "ms" : "-"}</td>
408
+ </tr>`).join("")}
409
+ </tbody>
410
+ </table>
411
+ </div>` : ""}
412
+
413
+ ${queries.length > 0 ? `
414
+ <h2>Database Queries</h2>
415
+ <div class="section">
416
+ <table>
417
+ <thead><tr><th>Query</th><th>Duration</th></tr></thead>
418
+ <tbody>
419
+ ${queries.slice(0, 20).map((q) => `<tr>
420
+ <td class="mono" style="max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml((q.query || q.sql || "?").substring(0, 100))}</td>
421
+ <td>${q.durationMs ? q.durationMs.toFixed(1) + "ms" : "-"}</td>
422
+ </tr>`).join("")}
423
+ </tbody>
424
+ </table>
425
+ </div>` : ""}
426
+ </div>
427
+ <footer>Powered by <a href="https://github.com/yiheinchai/trickle">trickle</a> — runtime observability</footer>
428
+ </body>
429
+ </html>`;
430
+ }
431
+ function escapeHtml(s) {
432
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
433
+ }
434
+ exports.default = router;
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ const dashboard_1 = __importDefault(require("./routes/dashboard"));
18
18
  const coverage_1 = __importDefault(require("./routes/coverage"));
19
19
  const audit_1 = __importDefault(require("./routes/audit"));
20
20
  const search_1 = __importDefault(require("./routes/search"));
21
+ const cloud_1 = __importDefault(require("./routes/cloud"));
21
22
  const app = (0, express_1.default)();
22
23
  exports.app = app;
23
24
  app.use((0, cors_1.default)());
@@ -34,6 +35,7 @@ app.use("/dashboard", dashboard_1.default);
34
35
  app.use("/api/coverage", coverage_1.default);
35
36
  app.use("/api/audit", audit_1.default);
36
37
  app.use("/api/search", search_1.default);
38
+ app.use("/api/v1", cloud_1.default);
37
39
  // Health check
38
40
  app.get("/api/health", (_req, res) => {
39
41
  res.json({ ok: true, timestamp: new Date().toISOString() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-backend",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -0,0 +1,61 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ export function runCloudMigrations(db: Database.Database): void {
4
+ db.exec(`
5
+ -- Projects table: each project is an isolated workspace
6
+ CREATE TABLE IF NOT EXISTS projects (
7
+ id TEXT PRIMARY KEY,
8
+ name TEXT NOT NULL,
9
+ owner_key_id TEXT NOT NULL,
10
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
11
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
12
+ settings TEXT DEFAULT '{}'
13
+ );
14
+
15
+ -- API keys for authentication
16
+ CREATE TABLE IF NOT EXISTS api_keys (
17
+ id TEXT PRIMARY KEY,
18
+ key_hash TEXT NOT NULL UNIQUE,
19
+ key_prefix TEXT NOT NULL,
20
+ name TEXT NOT NULL DEFAULT 'default',
21
+ owner_email TEXT,
22
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
23
+ last_used_at TEXT,
24
+ revoked INTEGER NOT NULL DEFAULT 0
25
+ );
26
+
27
+ -- Project data: stores all JSONL/JSON files per project
28
+ CREATE TABLE IF NOT EXISTS project_data (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ project_id TEXT NOT NULL REFERENCES projects(id),
31
+ filename TEXT NOT NULL,
32
+ content TEXT NOT NULL,
33
+ size_bytes INTEGER NOT NULL DEFAULT 0,
34
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now')),
35
+ UNIQUE(project_id, filename)
36
+ );
37
+
38
+ -- Push history for audit trail
39
+ CREATE TABLE IF NOT EXISTS push_history (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ project_id TEXT NOT NULL REFERENCES projects(id),
42
+ key_id TEXT NOT NULL,
43
+ file_count INTEGER NOT NULL,
44
+ total_bytes INTEGER NOT NULL,
45
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
46
+ );
47
+
48
+ -- Shared dashboards (public read-only links)
49
+ CREATE TABLE IF NOT EXISTS share_links (
50
+ id TEXT PRIMARY KEY,
51
+ project_id TEXT NOT NULL REFERENCES projects(id),
52
+ created_by TEXT NOT NULL,
53
+ expires_at TEXT,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
58
+ CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
59
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
60
+ `);
61
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { app } from "./server";
2
2
  import { db } from "./db/connection";
3
3
  import { runMigrations } from "./db/migrations";
4
+ import { runCloudMigrations } from "./db/cloud-migrations";
4
5
 
5
6
  runMigrations(db);
7
+ runCloudMigrations(db);
6
8
 
7
9
  const PORT = parseInt(process.env.PORT || "4888", 10);
8
10
 
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Cloud API routes — /api/v1/*
3
+ *
4
+ * Provides multi-tenant cloud observability:
5
+ * POST /api/v1/push — Upload .trickle/ data for a project
6
+ * GET /api/v1/pull — Download project data
7
+ * GET /api/v1/projects — List projects for authenticated user
8
+ * POST /api/v1/projects — Create a new project
9
+ * POST /api/v1/keys — Generate a new API key
10
+ * POST /api/v1/share — Create a shareable dashboard link
11
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
12
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
13
+ */
14
+
15
+ import { Router, Request, Response, NextFunction } from "express";
16
+ import crypto from "crypto";
17
+ import { db } from "../db/connection";
18
+
19
+ const router = Router();
20
+
21
+ // ── Helpers ──
22
+
23
+ function generateId(): string {
24
+ return crypto.randomBytes(12).toString("hex");
25
+ }
26
+
27
+ function generateApiKey(): { key: string; hash: string; prefix: string } {
28
+ const key = `tk_${crypto.randomBytes(24).toString("hex")}`;
29
+ const hash = crypto.createHash("sha256").update(key).digest("hex");
30
+ const prefix = key.slice(0, 7);
31
+ return { key, hash, prefix };
32
+ }
33
+
34
+ function hashKey(key: string): string {
35
+ return crypto.createHash("sha256").update(key).digest("hex");
36
+ }
37
+
38
+ // ── Auth middleware ──
39
+
40
+ interface AuthedRequest extends Request {
41
+ keyId?: string;
42
+ keyName?: string;
43
+ }
44
+
45
+ function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
46
+ const authHeader = req.headers.authorization;
47
+ if (!authHeader?.startsWith("Bearer ")) {
48
+ res.status(401).json({ error: "Missing or invalid Authorization header. Use: Bearer <api-key>" });
49
+ return;
50
+ }
51
+
52
+ const token = authHeader.slice(7);
53
+ const tokenHash = hashKey(token);
54
+
55
+ const key = db.prepare(
56
+ "SELECT id, name, revoked FROM api_keys WHERE key_hash = ?"
57
+ ).get(tokenHash) as any;
58
+
59
+ if (!key) {
60
+ res.status(401).json({ error: "Invalid API key" });
61
+ return;
62
+ }
63
+
64
+ if (key.revoked) {
65
+ res.status(403).json({ error: "API key has been revoked" });
66
+ return;
67
+ }
68
+
69
+ // Update last_used_at
70
+ db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(key.id);
71
+
72
+ req.keyId = key.id;
73
+ req.keyName = key.name;
74
+ next();
75
+ }
76
+
77
+ // ── POST /api/v1/keys — Generate a new API key ──
78
+
79
+ router.post("/keys", (req: Request, res: Response) => {
80
+ const { name, email } = req.body || {};
81
+ const { key, hash, prefix } = generateApiKey();
82
+ const id = generateId();
83
+
84
+ db.prepare(
85
+ "INSERT INTO api_keys (id, key_hash, key_prefix, name, owner_email) VALUES (?, ?, ?, ?, ?)"
86
+ ).run(id, hash, prefix, name || "default", email || null);
87
+
88
+ res.status(201).json({
89
+ id,
90
+ key, // Only returned once — user must save it
91
+ prefix,
92
+ name: name || "default",
93
+ message: "Save this key — it cannot be retrieved later.",
94
+ });
95
+ });
96
+
97
+ // ── POST /api/v1/push — Upload project data ──
98
+
99
+ router.post("/push", requireAuth, (req: AuthedRequest, res: Response) => {
100
+ const { project, files, timestamp } = req.body;
101
+
102
+ if (!project || typeof project !== "string") {
103
+ res.status(400).json({ error: "project name required" });
104
+ return;
105
+ }
106
+ if (!files || typeof files !== "object") {
107
+ res.status(400).json({ error: "files object required" });
108
+ return;
109
+ }
110
+
111
+ const projectId = `${req.keyId}:${project}`;
112
+
113
+ // Upsert project
114
+ db.prepare(`
115
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
116
+ VALUES (?, ?, ?, datetime('now'))
117
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
118
+ `).run(projectId, project, req.keyId);
119
+
120
+ // Upsert each file
121
+ const upsertFile = db.prepare(`
122
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
123
+ VALUES (?, ?, ?, ?, datetime('now'))
124
+ ON CONFLICT(project_id, filename) DO UPDATE SET
125
+ content = excluded.content,
126
+ size_bytes = excluded.size_bytes,
127
+ pushed_at = datetime('now')
128
+ `);
129
+
130
+ let totalBytes = 0;
131
+ let fileCount = 0;
132
+
133
+ const insertMany = db.transaction(() => {
134
+ for (const [filename, content] of Object.entries(files)) {
135
+ if (typeof content !== "string") continue;
136
+ const bytes = Buffer.byteLength(content, "utf-8");
137
+ upsertFile.run(projectId, filename, content, bytes);
138
+ totalBytes += bytes;
139
+ fileCount++;
140
+ }
141
+ });
142
+ insertMany();
143
+
144
+ // Record push history
145
+ db.prepare(
146
+ "INSERT INTO push_history (project_id, key_id, file_count, total_bytes) VALUES (?, ?, ?, ?)"
147
+ ).run(projectId, req.keyId, fileCount, totalBytes);
148
+
149
+ const dashboardUrl = `${req.protocol}://${req.get("host")}/api/v1/dashboard/${encodeURIComponent(projectId)}`;
150
+
151
+ res.json({
152
+ ok: true,
153
+ project: projectId,
154
+ files: fileCount,
155
+ bytes: totalBytes,
156
+ url: dashboardUrl,
157
+ });
158
+ });
159
+
160
+ // ── GET /api/v1/pull — Download project data ──
161
+
162
+ router.get("/pull", requireAuth, (req: AuthedRequest, res: Response) => {
163
+ const project = req.query.project as string;
164
+ if (!project) {
165
+ res.status(400).json({ error: "project query parameter required" });
166
+ return;
167
+ }
168
+
169
+ const projectId = `${req.keyId}:${project}`;
170
+
171
+ const rows = db.prepare(
172
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
173
+ ).all(projectId) as any[];
174
+
175
+ if (rows.length === 0) {
176
+ res.status(404).json({ error: "No data found for this project" });
177
+ return;
178
+ }
179
+
180
+ const files: Record<string, string> = {};
181
+ for (const row of rows) {
182
+ files[row.filename] = row.content;
183
+ }
184
+
185
+ res.json({ project, files, fileCount: rows.length });
186
+ });
187
+
188
+ // ── GET /api/v1/projects — List projects ──
189
+
190
+ router.get("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
191
+ const rows = db.prepare(`
192
+ SELECT p.id, p.name, p.created_at, p.updated_at,
193
+ (SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
194
+ (SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
195
+ FROM projects p
196
+ WHERE p.owner_key_id = ?
197
+ ORDER BY p.updated_at DESC
198
+ `).all(req.keyId) as any[];
199
+
200
+ res.json({
201
+ projects: rows.map((r: any) => ({
202
+ id: r.id,
203
+ name: r.name,
204
+ files: r.file_count || 0,
205
+ size: r.total_bytes || 0,
206
+ createdAt: r.created_at,
207
+ updatedAt: r.updated_at,
208
+ })),
209
+ });
210
+ });
211
+
212
+ // ── POST /api/v1/projects — Create project ──
213
+
214
+ router.post("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
215
+ const { name } = req.body;
216
+ if (!name) {
217
+ res.status(400).json({ error: "name required" });
218
+ return;
219
+ }
220
+
221
+ const projectId = `${req.keyId}:${name}`;
222
+
223
+ db.prepare(`
224
+ INSERT INTO projects (id, name, owner_key_id)
225
+ VALUES (?, ?, ?)
226
+ ON CONFLICT(id) DO NOTHING
227
+ `).run(projectId, name, req.keyId);
228
+
229
+ res.status(201).json({ id: projectId, name });
230
+ });
231
+
232
+ // ── POST /api/v1/share — Create shareable link ──
233
+
234
+ router.post("/share", requireAuth, (req: AuthedRequest, res: Response) => {
235
+ const { project, expiresInHours } = req.body;
236
+ if (!project) {
237
+ res.status(400).json({ error: "project name required" });
238
+ return;
239
+ }
240
+
241
+ const projectId = `${req.keyId}:${project}`;
242
+
243
+ // Verify project exists
244
+ const proj = db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
245
+ if (!proj) {
246
+ res.status(404).json({ error: "Project not found" });
247
+ return;
248
+ }
249
+
250
+ const shareId = generateId();
251
+ const expiresAt = expiresInHours
252
+ ? new Date(Date.now() + expiresInHours * 3600000).toISOString()
253
+ : null;
254
+
255
+ db.prepare(
256
+ "INSERT INTO share_links (id, project_id, created_by, expires_at) VALUES (?, ?, ?, ?)"
257
+ ).run(shareId, projectId, req.keyId!, expiresAt);
258
+
259
+ const shareUrl = `${req.protocol}://${req.get("host")}/api/v1/shared/${shareId}`;
260
+
261
+ res.status(201).json({
262
+ shareId,
263
+ url: shareUrl,
264
+ expiresAt,
265
+ });
266
+ });
267
+
268
+ // ── GET /api/v1/shared/:id — View shared data (no auth) ──
269
+
270
+ router.get("/shared/:id", (req: Request, res: Response) => {
271
+ const link = db.prepare(`
272
+ SELECT sl.project_id, sl.expires_at, p.name as project_name
273
+ FROM share_links sl
274
+ JOIN projects p ON p.id = sl.project_id
275
+ WHERE sl.id = ?
276
+ `).get(req.params.id) as any;
277
+
278
+ if (!link) {
279
+ res.status(404).json({ error: "Share link not found" });
280
+ return;
281
+ }
282
+
283
+ if (link.expires_at && new Date(link.expires_at) < new Date()) {
284
+ res.status(410).json({ error: "Share link has expired" });
285
+ return;
286
+ }
287
+
288
+ const rows = db.prepare(
289
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
290
+ ).all(link.project_id) as any[];
291
+
292
+ const files: Record<string, string> = {};
293
+ for (const row of rows) {
294
+ files[row.filename] = row.content;
295
+ }
296
+
297
+ // If request accepts HTML, serve dashboard
298
+ if (req.accepts("html")) {
299
+ res.send(generateDashboardHtml(link.project_name, files));
300
+ return;
301
+ }
302
+
303
+ res.json({ project: link.project_name, files, fileCount: rows.length });
304
+ });
305
+
306
+ // ── GET /api/v1/dashboard/:projectId — Authenticated dashboard ──
307
+
308
+ router.get("/dashboard/:projectId", requireAuth, (req: AuthedRequest, res: Response) => {
309
+ const projectId = decodeURIComponent(req.params.projectId);
310
+
311
+ // Verify ownership
312
+ const proj = db.prepare(
313
+ "SELECT name FROM projects WHERE id = ? AND owner_key_id = ?"
314
+ ).get(projectId, req.keyId) as any;
315
+
316
+ if (!proj) {
317
+ res.status(404).json({ error: "Project not found" });
318
+ return;
319
+ }
320
+
321
+ const rows = db.prepare(
322
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
323
+ ).all(projectId) as any[];
324
+
325
+ const files: Record<string, string> = {};
326
+ for (const row of rows) {
327
+ files[row.filename] = row.content;
328
+ }
329
+
330
+ if (req.accepts("html")) {
331
+ res.send(generateDashboardHtml(proj.name, files));
332
+ return;
333
+ }
334
+
335
+ res.json({ project: proj.name, files, fileCount: rows.length });
336
+ });
337
+
338
+ // ── Dashboard HTML generator ──
339
+
340
+ function generateDashboardHtml(projectName: string, files: Record<string, string>): string {
341
+ // Parse data files
342
+ const parseJsonl = (content: string) =>
343
+ content.split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
344
+
345
+ const observations = files["observations.jsonl"] ? parseJsonl(files["observations.jsonl"]) : [];
346
+ const variables = files["variables.jsonl"] ? parseJsonl(files["variables.jsonl"]) : [];
347
+ const calltrace = files["calltrace.jsonl"] ? parseJsonl(files["calltrace.jsonl"]) : [];
348
+ const queries = files["queries.jsonl"] ? parseJsonl(files["queries.jsonl"]) : [];
349
+ const errors = files["errors.jsonl"] ? parseJsonl(files["errors.jsonl"]) : [];
350
+ const alerts = files["alerts.jsonl"] ? parseJsonl(files["alerts.jsonl"]) : [];
351
+ const profile = files["profile.jsonl"] ? parseJsonl(files["profile.jsonl"]) : [];
352
+ let environment: any = {};
353
+ try { if (files["environment.json"]) environment = JSON.parse(files["environment.json"]); } catch {}
354
+
355
+ const criticalAlerts = alerts.filter((a: any) => a.severity === "critical");
356
+ const warningAlerts = alerts.filter((a: any) => a.severity === "warning");
357
+ const endProfile = profile.find((p: any) => p.event === "end");
358
+
359
+ const status = criticalAlerts.length > 0 ? "critical" :
360
+ errors.length > 0 ? "error" :
361
+ warningAlerts.length > 0 ? "warning" : "healthy";
362
+
363
+ const statusColors: Record<string, string> = {
364
+ healthy: "#22c55e", warning: "#eab308", error: "#ef4444", critical: "#dc2626",
365
+ };
366
+
367
+ const slowFuncs = observations
368
+ .filter((o: any) => o.durationMs && o.durationMs > 10)
369
+ .sort((a: any, b: any) => (b.durationMs || 0) - (a.durationMs || 0))
370
+ .slice(0, 10);
371
+
372
+ return `<!DOCTYPE html>
373
+ <html lang="en">
374
+ <head>
375
+ <meta charset="utf-8">
376
+ <meta name="viewport" content="width=device-width, initial-scale=1">
377
+ <title>${projectName} — trickle dashboard</title>
378
+ <style>
379
+ * { margin: 0; padding: 0; box-sizing: border-box; }
380
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e5e7eb; line-height: 1.5; }
381
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
382
+ h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
383
+ h2 { font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #9ca3af; }
384
+ .subtitle { color: #6b7280; font-size: 14px; margin-bottom: 24px; }
385
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
386
+ .card { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; }
387
+ .card-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
388
+ .card-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
389
+ .status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
390
+ table { width: 100%; border-collapse: collapse; }
391
+ th { text-align: left; padding: 8px 12px; font-size: 12px; color: #9ca3af; text-transform: uppercase; border-bottom: 1px solid #374151; }
392
+ td { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 14px; }
393
+ .mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
394
+ .bar { height: 8px; border-radius: 4px; background: #3b82f6; display: inline-block; min-width: 4px; }
395
+ .alert-critical { border-left: 3px solid #ef4444; }
396
+ .alert-warning { border-left: 3px solid #eab308; }
397
+ .error-card { background: #1f2937; border-left: 3px solid #ef4444; padding: 12px; margin-bottom: 8px; border-radius: 4px; }
398
+ .section { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; margin-bottom: 16px; }
399
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; background: #374151; color: #9ca3af; margin-right: 4px; }
400
+ footer { text-align: center; padding: 24px; color: #4b5563; font-size: 12px; }
401
+ a { color: #3b82f6; text-decoration: none; }
402
+ </style>
403
+ </head>
404
+ <body>
405
+ <div class="container">
406
+ <h1>${projectName}</h1>
407
+ <div class="subtitle">
408
+ <span class="status-badge" style="background: ${statusColors[status]}20; color: ${statusColors[status]}">${status.toUpperCase()}</span>
409
+ ${environment.node ? `<span class="tag">Node ${environment.node.version}</span>` : ""}
410
+ ${environment.python ? `<span class="tag">Python ${environment.python}</span>` : ""}
411
+ ${endProfile ? `<span class="tag">${Math.round((endProfile.rssKb || 0) / 1024)}MB RSS</span>` : ""}
412
+ </div>
413
+
414
+ <div class="grid">
415
+ <div class="card">
416
+ <div class="card-label">Functions</div>
417
+ <div class="card-value">${observations.length}</div>
418
+ </div>
419
+ <div class="card">
420
+ <div class="card-label">Variables</div>
421
+ <div class="card-value">${variables.length}</div>
422
+ </div>
423
+ <div class="card">
424
+ <div class="card-label">DB Queries</div>
425
+ <div class="card-value">${queries.length}</div>
426
+ </div>
427
+ <div class="card">
428
+ <div class="card-label">Call Trace</div>
429
+ <div class="card-value">${calltrace.length}</div>
430
+ </div>
431
+ <div class="card">
432
+ <div class="card-label">Errors</div>
433
+ <div class="card-value" style="color: ${errors.length > 0 ? "#ef4444" : "#22c55e"}">${errors.length}</div>
434
+ </div>
435
+ <div class="card">
436
+ <div class="card-label">Alerts</div>
437
+ <div class="card-value" style="color: ${criticalAlerts.length > 0 ? "#ef4444" : warningAlerts.length > 0 ? "#eab308" : "#22c55e"}">${alerts.length}</div>
438
+ </div>
439
+ </div>
440
+
441
+ ${alerts.length > 0 ? `
442
+ <h2>Alerts</h2>
443
+ <div class="section">
444
+ ${alerts.map((a: any) => `
445
+ <div class="card ${a.severity === "critical" ? "alert-critical" : "alert-warning"}" style="margin-bottom: 8px;">
446
+ <strong>${a.severity === "critical" ? "CRITICAL" : "WARNING"}</strong>: ${escapeHtml(a.message || "")}
447
+ ${a.suggestion ? `<div style="color: #9ca3af; font-size: 13px; margin-top: 4px;">Fix: ${escapeHtml(a.suggestion)}</div>` : ""}
448
+ </div>
449
+ `).join("")}
450
+ </div>` : ""}
451
+
452
+ ${errors.length > 0 ? `
453
+ <h2>Errors</h2>
454
+ <div class="section">
455
+ ${errors.slice(0, 10).map((e: any) => `
456
+ <div class="error-card">
457
+ <strong class="mono">${escapeHtml(e.type || "Error")}</strong>: ${escapeHtml((e.message || "").substring(0, 200))}
458
+ ${e.function ? `<div style="color: #6b7280; font-size: 12px;">in ${escapeHtml(e.function)}</div>` : ""}
459
+ </div>
460
+ `).join("")}
461
+ </div>` : ""}
462
+
463
+ ${slowFuncs.length > 0 ? `
464
+ <h2>Performance Hotspots</h2>
465
+ <div class="section">
466
+ <table>
467
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th><th></th></tr></thead>
468
+ <tbody>
469
+ ${slowFuncs.map((f: any) => {
470
+ const pct = Math.round((f.durationMs / slowFuncs[0].durationMs) * 100);
471
+ return `<tr>
472
+ <td class="mono">${escapeHtml(f.functionName || "?")}</td>
473
+ <td>${escapeHtml(f.module || "?")}</td>
474
+ <td>${f.durationMs?.toFixed(0)}ms</td>
475
+ <td><div class="bar" style="width: ${pct}%"></div></td>
476
+ </tr>`;
477
+ }).join("")}
478
+ </tbody>
479
+ </table>
480
+ </div>` : ""}
481
+
482
+ ${observations.length > 0 ? `
483
+ <h2>Observed Functions</h2>
484
+ <div class="section">
485
+ <table>
486
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th></tr></thead>
487
+ <tbody>
488
+ ${observations.slice(0, 30).map((o: any) => `<tr>
489
+ <td class="mono">${escapeHtml(o.functionName || "?")}</td>
490
+ <td>${escapeHtml(o.module || "?")}</td>
491
+ <td>${o.durationMs ? o.durationMs.toFixed(0) + "ms" : "-"}</td>
492
+ </tr>`).join("")}
493
+ </tbody>
494
+ </table>
495
+ </div>` : ""}
496
+
497
+ ${queries.length > 0 ? `
498
+ <h2>Database Queries</h2>
499
+ <div class="section">
500
+ <table>
501
+ <thead><tr><th>Query</th><th>Duration</th></tr></thead>
502
+ <tbody>
503
+ ${queries.slice(0, 20).map((q: any) => `<tr>
504
+ <td class="mono" style="max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml((q.query || q.sql || "?").substring(0, 100))}</td>
505
+ <td>${q.durationMs ? q.durationMs.toFixed(1) + "ms" : "-"}</td>
506
+ </tr>`).join("")}
507
+ </tbody>
508
+ </table>
509
+ </div>` : ""}
510
+ </div>
511
+ <footer>Powered by <a href="https://github.com/yiheinchai/trickle">trickle</a> — runtime observability</footer>
512
+ </body>
513
+ </html>`;
514
+ }
515
+
516
+ function escapeHtml(s: string): string {
517
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
518
+ }
519
+
520
+ export default router;
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import dashboardRouter from "./routes/dashboard";
13
13
  import coverageRouter from "./routes/coverage";
14
14
  import auditRouter from "./routes/audit";
15
15
  import searchRouter from "./routes/search";
16
+ import cloudRouter from "./routes/cloud";
16
17
 
17
18
  const app = express();
18
19
 
@@ -31,6 +32,7 @@ app.use("/dashboard", dashboardRouter);
31
32
  app.use("/api/coverage", coverageRouter);
32
33
  app.use("/api/audit", auditRouter);
33
34
  app.use("/api/search", searchRouter);
35
+ app.use("/api/v1", cloudRouter);
34
36
 
35
37
  // Health check
36
38
  app.get("/api/health", (_req, res) => {