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.
- package/dist/db/cloud-migrations.d.ts +2 -0
- package/dist/db/cloud-migrations.js +62 -0
- package/dist/index.js +2 -0
- package/dist/routes/cloud.d.ts +15 -0
- package/dist/routes/cloud.js +434 -0
- package/dist/server.js +2 -0
- package/package.json +1 -1
- package/src/db/cloud-migrations.ts +61 -0
- package/src/index.ts +2 -0
- package/src/routes/cloud.ts +520 -0
- package/src/server.ts +2 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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) => {
|