@thingd/cli 0.76.0 → 0.77.2
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/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +669 -0
- package/dist/commands/mcp-connect.d.ts +3 -0
- package/dist/commands/mcp-connect.d.ts.map +1 -0
- package/dist/commands/mcp-connect.js +154 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +197 -0
- package/dist/dashboard/public/assets/favicon-CgGFvG_0.svg +4 -0
- package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
- package/dist/dashboard/public/assets/index-DrpfyClj.js +4 -0
- package/dist/dashboard/public/index.html +19 -0
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +684 -0
- package/dist/data-movement.d.ts +6 -0
- package/dist/data-movement.d.ts.map +1 -0
- package/dist/data-movement.js +475 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +108 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1446 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +229 -0
- package/dist/interactive.d.ts +2 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +3039 -0
- package/dist/lib/cloud-api.d.ts +143 -0
- package/dist/lib/cloud-api.d.ts.map +1 -0
- package/dist/lib/cloud-api.js +207 -0
- package/dist/lib/cloud-config.d.ts +33 -0
- package/dist/lib/cloud-config.d.ts.map +1 -0
- package/dist/lib/cloud-config.js +42 -0
- package/dist/lib/mcp-config-writer.d.ts +26 -0
- package/dist/lib/mcp-config-writer.d.ts.map +1 -0
- package/dist/lib/mcp-config-writer.js +86 -0
- package/dist/lib/sync-config.d.ts +22 -0
- package/dist/lib/sync-config.d.ts.map +1 -0
- package/dist/lib/sync-config.js +25 -0
- package/dist/logo.d.ts +3 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +8 -0
- package/dist/mcp/cluster.d.ts +69 -0
- package/dist/mcp/cluster.d.ts.map +1 -0
- package/dist/mcp/cluster.js +304 -0
- package/dist/mcp/config.d.ts +14 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +67 -0
- package/dist/mcp/http.d.ts +35 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +957 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp-http.d.ts +3 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +42 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +28 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { existsSync, promises as fs, statSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, extname, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { handleRestRequest, ThingD } from "@thingd/sdk";
|
|
7
|
+
import { readCloudConfig } from "../lib/cloud-config.js";
|
|
8
|
+
import { readSyncConfig } from "../lib/sync-config.js";
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const _require = createRequire(__filename);
|
|
12
|
+
let pkgVersion = "0.0.0";
|
|
13
|
+
try {
|
|
14
|
+
const pkg = _require("../../package.json");
|
|
15
|
+
pkgVersion = pkg.version || "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// fallback — try relative to dist
|
|
19
|
+
try {
|
|
20
|
+
const pkg = _require("../package.json");
|
|
21
|
+
pkgVersion = pkg.version || "0.0.0";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// leave default
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Candidate public folders to support both tsx dev and compiled dist packaging
|
|
28
|
+
const publicDirCandidates = [
|
|
29
|
+
join(__dirname, "public"),
|
|
30
|
+
join(__dirname, "../public"),
|
|
31
|
+
join(__dirname, "../../../src/dashboard/public"),
|
|
32
|
+
join(__dirname, "../../src/dashboard/public"),
|
|
33
|
+
];
|
|
34
|
+
let publicDir = "";
|
|
35
|
+
for (const cand of publicDirCandidates) {
|
|
36
|
+
if (existsSync(cand)) {
|
|
37
|
+
publicDir = cand;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const MIME_TYPES = {
|
|
42
|
+
".html": "text/html",
|
|
43
|
+
".css": "text/css",
|
|
44
|
+
".js": "application/javascript",
|
|
45
|
+
".json": "application/json",
|
|
46
|
+
".png": "image/png",
|
|
47
|
+
".ico": "image/x-icon",
|
|
48
|
+
".svg": "image/svg+xml",
|
|
49
|
+
".webmanifest": "application/manifest+json",
|
|
50
|
+
};
|
|
51
|
+
async function readBody(req) {
|
|
52
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
53
|
+
let body = "";
|
|
54
|
+
req.on("data", (chunk) => {
|
|
55
|
+
body += chunk;
|
|
56
|
+
});
|
|
57
|
+
req.on("end", () => {
|
|
58
|
+
resolvePromise(body);
|
|
59
|
+
});
|
|
60
|
+
req.on("error", (err) => {
|
|
61
|
+
rejectPromise(err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function sendError(res, status, message) {
|
|
66
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
67
|
+
res.end(JSON.stringify({ error: message }));
|
|
68
|
+
}
|
|
69
|
+
function isCloudPath(path) {
|
|
70
|
+
return path.startsWith("http://") || path.startsWith("https://") || path.startsWith("thingd://");
|
|
71
|
+
}
|
|
72
|
+
export async function startDashboardServer(connectionOptions, port) {
|
|
73
|
+
// 1. Maintain dynamic active database options
|
|
74
|
+
let activeOptions = { ...connectionOptions };
|
|
75
|
+
let db = await ThingD.open({
|
|
76
|
+
path: activeOptions.path,
|
|
77
|
+
url: activeOptions.cloud ? activeOptions.path : undefined,
|
|
78
|
+
driver: activeOptions.driver,
|
|
79
|
+
authToken: activeOptions.authToken,
|
|
80
|
+
instanceSlug: activeOptions.instanceSlug,
|
|
81
|
+
});
|
|
82
|
+
// 2. Create HTTP Server
|
|
83
|
+
const server = createServer(async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(req.url || "", `http://${req.headers.host || "localhost"}`);
|
|
86
|
+
const pathname = url.pathname;
|
|
87
|
+
// Handle CORS for ease of developer integrations
|
|
88
|
+
const allowedOrigins = [
|
|
89
|
+
"http://localhost:8757",
|
|
90
|
+
"http://localhost:8758",
|
|
91
|
+
"http://127.0.0.1:8757",
|
|
92
|
+
"http://127.0.0.1:8758",
|
|
93
|
+
];
|
|
94
|
+
const origin = req.headers.origin;
|
|
95
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
96
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
100
|
+
}
|
|
101
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
102
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
103
|
+
if (req.method === "OPTIONS") {
|
|
104
|
+
res.writeHead(204);
|
|
105
|
+
res.end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// CSRF protection: state-changing requests must come from a known origin
|
|
109
|
+
if (req.method !== "GET" && req.method !== "OPTIONS" && req.method !== "HEAD") {
|
|
110
|
+
const requestOrigin = req.headers.origin;
|
|
111
|
+
if (requestOrigin && !allowedOrigins.includes(requestOrigin)) {
|
|
112
|
+
sendError(res, 403, "Cross-origin state-changing requests are not allowed");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Security Gate middleware for API endpoints
|
|
117
|
+
const isApiRoute = pathname.startsWith("/api/");
|
|
118
|
+
const isConnectRoute = pathname === "/api/connect";
|
|
119
|
+
const isRestRoute = pathname.startsWith("/v1/");
|
|
120
|
+
if ((isApiRoute || isRestRoute) && !isConnectRoute && activeOptions.authToken) {
|
|
121
|
+
const authHeader = req.headers.authorization;
|
|
122
|
+
const expectedHeader = `Bearer ${activeOptions.authToken}`;
|
|
123
|
+
if (!authHeader || authHeader !== expectedHeader) {
|
|
124
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
125
|
+
res.end(JSON.stringify({ error: "Unauthorized. Valid auth token is required." }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// REST API Routes
|
|
130
|
+
if (pathname.startsWith("/api/")) {
|
|
131
|
+
// POST /api/connect (Dynamic connection swapping)
|
|
132
|
+
if (pathname === "/api/connect" && req.method === "POST") {
|
|
133
|
+
const bodyStr = await readBody(req);
|
|
134
|
+
const { path, driver, authToken, instanceSlug } = JSON.parse(bodyStr);
|
|
135
|
+
if (!path || !driver) {
|
|
136
|
+
sendError(res, 400, "Fields 'path' and 'driver' are required.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const cloudMode = isCloudPath(path);
|
|
140
|
+
const resolvedToken = authToken || (cloudMode ? readCloudConfig()?.token : undefined);
|
|
141
|
+
const resolvedInstanceSlug = instanceSlug || activeOptions.instanceSlug;
|
|
142
|
+
// Safely shut down the old db instance
|
|
143
|
+
await db.close();
|
|
144
|
+
// Spawn new db connection dynamically
|
|
145
|
+
db = await ThingD.open({
|
|
146
|
+
path,
|
|
147
|
+
url: cloudMode ? path : undefined,
|
|
148
|
+
driver,
|
|
149
|
+
authToken: resolvedToken,
|
|
150
|
+
instanceSlug: resolvedInstanceSlug,
|
|
151
|
+
});
|
|
152
|
+
activeOptions = {
|
|
153
|
+
path,
|
|
154
|
+
driver,
|
|
155
|
+
authToken: resolvedToken,
|
|
156
|
+
cloud: cloudMode,
|
|
157
|
+
instanceSlug: resolvedInstanceSlug,
|
|
158
|
+
};
|
|
159
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify({ success: true, path, driver }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// GET /api/status
|
|
164
|
+
if (pathname === "/api/status" && req.method === "GET") {
|
|
165
|
+
const [objects, events, activeJobs, deadJobs] = await Promise.all([
|
|
166
|
+
db.countObjects(),
|
|
167
|
+
db.countEvents(),
|
|
168
|
+
db.countActiveJobs(),
|
|
169
|
+
db.countDeadJobs(),
|
|
170
|
+
]);
|
|
171
|
+
let dbSize = "N/A (in-memory)";
|
|
172
|
+
if (activeOptions.driver === "native" && existsSync(activeOptions.path)) {
|
|
173
|
+
try {
|
|
174
|
+
const stats = statSync(activeOptions.path);
|
|
175
|
+
dbSize = `${(stats.size / 1024).toFixed(1)} KB`;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
dbSize = "N/A (error)";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
182
|
+
res.end(JSON.stringify({
|
|
183
|
+
version: pkgVersion,
|
|
184
|
+
mode: activeOptions.cloud ? "cloud" : "local",
|
|
185
|
+
driver: activeOptions.driver || "memory",
|
|
186
|
+
path: activeOptions.path,
|
|
187
|
+
metrics: { objects, events, activeJobs, deadJobs, dbSize },
|
|
188
|
+
authRequired: !!activeOptions.authToken,
|
|
189
|
+
}));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (pathname === "/api/replication/status" && req.method === "GET") {
|
|
193
|
+
const config = readSyncConfig();
|
|
194
|
+
const replicationEvents = await db.events.list("__thingd:system:replication", {
|
|
195
|
+
limit: 1_000_000,
|
|
196
|
+
});
|
|
197
|
+
const last = replicationEvents.at(-1);
|
|
198
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
199
|
+
res.end(JSON.stringify({
|
|
200
|
+
configured: Boolean(config),
|
|
201
|
+
config,
|
|
202
|
+
sourceId: config?.sourceId,
|
|
203
|
+
latestCursor: last?.sequence ?? 0,
|
|
204
|
+
protectedCloudTarget: config?.provider === "thingd.cloud" && !config.allowCloudTarget,
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// GET /api/collections
|
|
209
|
+
if (pathname === "/api/collections" && req.method === "GET") {
|
|
210
|
+
const collections = await db.listCollections();
|
|
211
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
212
|
+
res.end(JSON.stringify(collections));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// GET/POST/DELETE /api/objects
|
|
216
|
+
if (pathname === "/api/objects") {
|
|
217
|
+
if (req.method === "GET") {
|
|
218
|
+
const collection = url.searchParams.get("collection");
|
|
219
|
+
if (!collection) {
|
|
220
|
+
sendError(res, 400, "Query parameter 'collection' is required.");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const objects = await db.listObjects(collection);
|
|
224
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
225
|
+
res.end(JSON.stringify(objects));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.method === "POST") {
|
|
229
|
+
const bodyStr = await readBody(req);
|
|
230
|
+
const { collection, id, text, data } = JSON.parse(bodyStr);
|
|
231
|
+
if (!collection || !id) {
|
|
232
|
+
sendError(res, 400, "Fields 'collection' and 'id' are required.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const result = await db.put(collection, { id, text, ...data });
|
|
236
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
237
|
+
res.end(JSON.stringify(result));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (req.method === "DELETE") {
|
|
241
|
+
const collection = url.searchParams.get("collection");
|
|
242
|
+
const id = url.searchParams.get("id");
|
|
243
|
+
if (!collection || !id) {
|
|
244
|
+
sendError(res, 400, "Query parameters 'collection' and 'id' are required.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const result = await db.delete(collection, id);
|
|
248
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
249
|
+
res.end(JSON.stringify(result));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// GET/POST /api/events
|
|
254
|
+
if (pathname === "/api/events") {
|
|
255
|
+
if (req.method === "GET") {
|
|
256
|
+
const stream = url.searchParams.get("stream") || undefined;
|
|
257
|
+
const limitVal = url.searchParams.get("limit");
|
|
258
|
+
const limit = limitVal ? Number.parseInt(limitVal, 10) : undefined;
|
|
259
|
+
const events = await db.events.list(stream);
|
|
260
|
+
const sliced = limit ? events.slice(0, limit) : events;
|
|
261
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
262
|
+
res.end(JSON.stringify(sliced));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (req.method === "POST") {
|
|
266
|
+
const bodyStr = await readBody(req);
|
|
267
|
+
const { stream, type, text, data } = JSON.parse(bodyStr);
|
|
268
|
+
if (!stream || !type) {
|
|
269
|
+
sendError(res, 400, "Fields 'stream' and 'type' are required.");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const result = await db.events.append(stream, { type, text, ...data });
|
|
273
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
274
|
+
res.end(JSON.stringify(result));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// GET /api/events/streams
|
|
279
|
+
if (pathname === "/api/events/streams" && req.method === "GET") {
|
|
280
|
+
const streams = await db.listStreams();
|
|
281
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
282
|
+
res.end(JSON.stringify(streams));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// GET /api/queues
|
|
286
|
+
if (pathname === "/api/queues" && req.method === "GET") {
|
|
287
|
+
const queues = await db.listQueues();
|
|
288
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
289
|
+
res.end(JSON.stringify(queues));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// GET /api/queues/jobs
|
|
293
|
+
if (pathname === "/api/queues/jobs" && req.method === "GET") {
|
|
294
|
+
const queue = url.searchParams.get("queue");
|
|
295
|
+
const status = url.searchParams.get("status");
|
|
296
|
+
if (!queue) {
|
|
297
|
+
sendError(res, 400, "Query parameter 'queue' is required.");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const q = db.queue(queue);
|
|
301
|
+
const jobs = status === "dead" ? await q.dead() : await q.list();
|
|
302
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
303
|
+
res.end(JSON.stringify(jobs));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// GET /api/queues/stats
|
|
307
|
+
if (pathname === "/api/queues/stats" && req.method === "GET") {
|
|
308
|
+
const queue = url.searchParams.get("queue");
|
|
309
|
+
if (!queue) {
|
|
310
|
+
sendError(res, 400, "Query parameter 'queue' is required.");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const q = db.queue(queue);
|
|
314
|
+
const [activeJobs, deadJobs] = await Promise.all([q.list(), q.dead()]);
|
|
315
|
+
const leased = activeJobs.filter((j) => j.status === "leased").length;
|
|
316
|
+
const ready = activeJobs.filter((j) => j.status === "ready").length;
|
|
317
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
318
|
+
res.end(JSON.stringify({
|
|
319
|
+
queue,
|
|
320
|
+
totalActive: activeJobs.length,
|
|
321
|
+
ready,
|
|
322
|
+
leased,
|
|
323
|
+
dead: deadJobs.length,
|
|
324
|
+
}));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// POST /api/queues/push
|
|
328
|
+
if (pathname === "/api/queues/push" && req.method === "POST") {
|
|
329
|
+
const bodyStr = await readBody(req);
|
|
330
|
+
const { queue, payload, delayMs, maxAttempts, idempotencyKey } = JSON.parse(bodyStr);
|
|
331
|
+
if (!queue || !payload) {
|
|
332
|
+
sendError(res, 400, "Fields 'queue' and 'payload' are required.");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const q = db.queue(queue);
|
|
336
|
+
const result = await q.push(payload, { delayMs, maxAttempts, idempotencyKey });
|
|
337
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
338
|
+
res.end(JSON.stringify(result));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// POST /api/queues/claim
|
|
342
|
+
if (pathname === "/api/queues/claim" && req.method === "POST") {
|
|
343
|
+
const bodyStr = await readBody(req);
|
|
344
|
+
const { queue, leaseMs } = JSON.parse(bodyStr);
|
|
345
|
+
if (!queue) {
|
|
346
|
+
sendError(res, 400, "Field 'queue' is required.");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const q = db.queue(queue);
|
|
350
|
+
const job = await q.claim({ leaseMs });
|
|
351
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
352
|
+
res.end(JSON.stringify(job || null));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// POST /api/queues/ack
|
|
356
|
+
if (pathname === "/api/queues/ack" && req.method === "POST") {
|
|
357
|
+
const bodyStr = await readBody(req);
|
|
358
|
+
const { queue, jobId } = JSON.parse(bodyStr);
|
|
359
|
+
if (!queue || !jobId) {
|
|
360
|
+
sendError(res, 400, "Fields 'queue' and 'jobId' are required.");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const q = db.queue(queue);
|
|
364
|
+
const result = await q.ack(jobId);
|
|
365
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
366
|
+
res.end(JSON.stringify(result));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// POST /api/queues/nack
|
|
370
|
+
if (pathname === "/api/queues/nack" && req.method === "POST") {
|
|
371
|
+
const bodyStr = await readBody(req);
|
|
372
|
+
const { queue, jobId, error, delayMs } = JSON.parse(bodyStr);
|
|
373
|
+
if (!queue || !jobId) {
|
|
374
|
+
sendError(res, 400, "Fields 'queue' and 'jobId' are required.");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const q = db.queue(queue);
|
|
378
|
+
const result = await q.nack(jobId, { error, delayMs });
|
|
379
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
380
|
+
res.end(JSON.stringify(result));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// GET /api/search
|
|
384
|
+
if (pathname === "/api/search" && req.method === "GET") {
|
|
385
|
+
const query = url.searchParams.get("query");
|
|
386
|
+
const limitVal = url.searchParams.get("limit");
|
|
387
|
+
const collectionsStr = url.searchParams.get("collections");
|
|
388
|
+
const filterStr = url.searchParams.get("filter");
|
|
389
|
+
if (!query) {
|
|
390
|
+
sendError(res, 400, "Query parameter 'query' is required.");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const limit = limitVal ? Number.parseInt(limitVal, 10) : undefined;
|
|
394
|
+
const collections = collectionsStr ? collectionsStr.split(",") : undefined;
|
|
395
|
+
const filter = filterStr ? JSON.parse(filterStr) : undefined;
|
|
396
|
+
const results = await db.search(query, { limit, collections, filter });
|
|
397
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
398
|
+
res.end(JSON.stringify(results));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// GET /api/schema
|
|
402
|
+
if (pathname === "/api/schema" && req.method === "GET") {
|
|
403
|
+
const collection = url.searchParams.get("collection") || undefined;
|
|
404
|
+
const schemas = await db.schema(collection);
|
|
405
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
406
|
+
res.end(JSON.stringify(schemas));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// POST /api/nlq
|
|
410
|
+
if (pathname === "/api/nlq" && req.method === "POST") {
|
|
411
|
+
const bodyStr = await readBody(req);
|
|
412
|
+
const { question, collection, model, endpoint, apiKey } = JSON.parse(bodyStr);
|
|
413
|
+
if (!question) {
|
|
414
|
+
sendError(res, 400, "Field 'question' is required.");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const llmModel = model || "llama3";
|
|
418
|
+
const llmEndpoint = (endpoint || "http://localhost:11434/v1").replace(/\/+$/, "");
|
|
419
|
+
const llmApiKey = apiKey || "";
|
|
420
|
+
// Step 1: Reflect schema
|
|
421
|
+
const schemas = await db.schema(collection || undefined);
|
|
422
|
+
if (!schemas || schemas.length === 0) {
|
|
423
|
+
sendError(res, 400, "No collections found. Add objects first or specify a valid collection.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Step 2: Build prompt and call LLM
|
|
427
|
+
const systemPrompt = `You are a data analysis assistant. The user has a thingd database with these collections and inferred schemas:
|
|
428
|
+
|
|
429
|
+
${JSON.stringify(schemas, null, 2)}
|
|
430
|
+
|
|
431
|
+
You can perform these operations on the data:
|
|
432
|
+
- "aggregate": count/sum/avg/min/max with optional groupBy
|
|
433
|
+
- "timeseries": time-bucketed aggregation by hour/day/week/month
|
|
434
|
+
- "search": full-text search across objects
|
|
435
|
+
|
|
436
|
+
Respond with ONLY a JSON object (no markdown, no explanation) matching this type:
|
|
437
|
+
{
|
|
438
|
+
"action": "aggregate" | "timeseries" | "search",
|
|
439
|
+
"collection": "string (collection name)",
|
|
440
|
+
"function": "count" | "sum" | "avg" | "min" | "max" (omit for search)",
|
|
441
|
+
"field": "string (field name for sum/avg/min/max, omit for count)",
|
|
442
|
+
"groupBy": "string (field name to group by, optional)",
|
|
443
|
+
"bucket": "hour" | "day" | "week" | "month" (only for timeseries)",
|
|
444
|
+
"query": "string (search query, only for search action)",
|
|
445
|
+
"limit": number (optional, max 100)
|
|
446
|
+
}}
|
|
447
|
+
|
|
448
|
+
Example: { "action": "aggregate", "collection": "orders", "function": "sum", "field": "revenue", "groupBy": "region" }`;
|
|
449
|
+
const llmResponse = await fetch(`${llmEndpoint}/chat/completions`, {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: {
|
|
452
|
+
"Content-Type": "application/json",
|
|
453
|
+
...(llmApiKey ? { Authorization: `Bearer ${llmApiKey}` } : {}),
|
|
454
|
+
},
|
|
455
|
+
body: JSON.stringify({
|
|
456
|
+
model: llmModel,
|
|
457
|
+
messages: [
|
|
458
|
+
{ role: "system", content: systemPrompt },
|
|
459
|
+
{ role: "user", content: question },
|
|
460
|
+
],
|
|
461
|
+
max_tokens: 1024,
|
|
462
|
+
temperature: 0.1,
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
if (!llmResponse.ok) {
|
|
466
|
+
const errText = await llmResponse.text();
|
|
467
|
+
console.error("LLM request failed:", llmResponse.status, errText);
|
|
468
|
+
sendError(res, 502, "LLM request failed");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const llmData = await llmResponse.json();
|
|
472
|
+
const llmText = llmData.choices?.[0]?.message?.content;
|
|
473
|
+
if (!llmText) {
|
|
474
|
+
sendError(res, 502, "LLM returned no choices");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Step 3: Parse intent
|
|
478
|
+
const cleaned = llmText
|
|
479
|
+
.trim()
|
|
480
|
+
.replace(/^```(?:json)?\s*/, "")
|
|
481
|
+
.replace(/\s*```$/, "")
|
|
482
|
+
.trim();
|
|
483
|
+
let intent;
|
|
484
|
+
try {
|
|
485
|
+
intent = JSON.parse(cleaned);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
sendError(res, 502, `Failed to parse LLM response as JSON: ${cleaned}`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// Step 4: Execute
|
|
492
|
+
let data;
|
|
493
|
+
switch (intent.action) {
|
|
494
|
+
case "aggregate": {
|
|
495
|
+
const fn = intent.function || "count";
|
|
496
|
+
const col = intent.collection;
|
|
497
|
+
if (fn === "count") {
|
|
498
|
+
data = await db.aggregate.count(col, {
|
|
499
|
+
groupBy: intent.groupBy,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
else if (fn === "sum") {
|
|
503
|
+
data = await db.aggregate.sum(col, intent.field, {
|
|
504
|
+
groupBy: intent.groupBy,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
else if (fn === "avg") {
|
|
508
|
+
data = await db.aggregate.avg(col, intent.field, {
|
|
509
|
+
groupBy: intent.groupBy,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
else if (fn === "min") {
|
|
513
|
+
data = await db.aggregate.min(col, intent.field, {
|
|
514
|
+
groupBy: intent.groupBy,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
data = await db.aggregate.max(col, intent.field, {
|
|
519
|
+
groupBy: intent.groupBy,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "timeseries": {
|
|
525
|
+
const bucket = (intent.bucket || "day");
|
|
526
|
+
data = await db.timeseries(intent.collection, {
|
|
527
|
+
function: (intent.function || "count"),
|
|
528
|
+
bucket,
|
|
529
|
+
field: intent.field,
|
|
530
|
+
});
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
case "search": {
|
|
534
|
+
data = await db.search(intent.query || question, {
|
|
535
|
+
collections: [intent.collection],
|
|
536
|
+
limit: intent.limit || 10,
|
|
537
|
+
});
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
default:
|
|
541
|
+
sendError(res, 400, `Unknown action: ${intent.action}`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const result = {
|
|
545
|
+
answer: formatAnswer(intent, data),
|
|
546
|
+
data,
|
|
547
|
+
intent,
|
|
548
|
+
};
|
|
549
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
550
|
+
res.end(JSON.stringify(result));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// GET /api/db/checkpoint
|
|
554
|
+
if (pathname === "/api/db/checkpoint" && req.method === "GET") {
|
|
555
|
+
try {
|
|
556
|
+
const result = db.walCheckpoint();
|
|
557
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
558
|
+
res.end(JSON.stringify(result));
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
console.error("Checkpoint failed:", e);
|
|
562
|
+
sendError(res, 400, "Checkpoint failed");
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// GET /api/db/integrity
|
|
567
|
+
if (pathname === "/api/db/integrity" && req.method === "GET") {
|
|
568
|
+
try {
|
|
569
|
+
await db.countObjects();
|
|
570
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
571
|
+
res.end(JSON.stringify({ ok: true, message: "Database is accessible" }));
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
console.error("Integrity check failed:", e);
|
|
575
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
576
|
+
res.end(JSON.stringify({ ok: false, message: "Database integrity check failed" }));
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// POST /api/backup
|
|
581
|
+
if (pathname === "/api/backup" && req.method === "POST") {
|
|
582
|
+
try {
|
|
583
|
+
let body = "";
|
|
584
|
+
for await (const chunk of req) {
|
|
585
|
+
body += chunk;
|
|
586
|
+
}
|
|
587
|
+
const { path: backupPath } = JSON.parse(body);
|
|
588
|
+
if (!backupPath) {
|
|
589
|
+
sendError(res, 400, "Missing 'path' in request body");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
db.backupTo(backupPath);
|
|
593
|
+
const { statSync } = await import("node:fs");
|
|
594
|
+
const stats = statSync(backupPath);
|
|
595
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
596
|
+
res.end(JSON.stringify({ path: backupPath, sizeBytes: stats.size }));
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
console.error("Backup failed:", e);
|
|
600
|
+
sendError(res, 500, "Backup failed");
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// GET /api/config/error-mode
|
|
605
|
+
if (pathname === "/api/config/error-mode" && req.method === "GET") {
|
|
606
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
607
|
+
res.end(JSON.stringify({ productionMode: false }));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
sendError(res, 404, `Endpoint ${req.method} ${pathname} not found.`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// REST API Routes (/v1/*)
|
|
614
|
+
if (pathname.startsWith("/v1/")) {
|
|
615
|
+
await handleRestRequest(db, req, res, pathname);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// Static File Server
|
|
619
|
+
const targetFilePath = pathname === "/" ? "index.html" : pathname.replace(/^\//, "");
|
|
620
|
+
const fullFilePath = join(publicDir, targetFilePath);
|
|
621
|
+
// Security: ensure the resolved path is inside the public folder
|
|
622
|
+
const relativePath = relative(publicDir, fullFilePath);
|
|
623
|
+
if (isAbsolute(relativePath) || relativePath.startsWith("..")) {
|
|
624
|
+
res.writeHead(403);
|
|
625
|
+
res.end("Forbidden");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (existsSync(fullFilePath)) {
|
|
629
|
+
const fileContent = await fs.readFile(fullFilePath);
|
|
630
|
+
const ext = extname(fullFilePath).toLowerCase();
|
|
631
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
632
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
633
|
+
res.end(fileContent);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
res.writeHead(404);
|
|
637
|
+
res.end("Not Found");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
console.error("Dashboard server exception:", err);
|
|
642
|
+
sendError(res, 500, "Internal server error");
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
646
|
+
server.listen(port, () => {
|
|
647
|
+
resolvePromise({
|
|
648
|
+
server,
|
|
649
|
+
close: async () => {
|
|
650
|
+
await new Promise((closeRes) => server.close(() => closeRes()));
|
|
651
|
+
await db.close();
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
server.on("error", (err) => {
|
|
656
|
+
rejectPromise(err);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function formatAnswer(intent, data) {
|
|
661
|
+
const fnName = intent.function || "count";
|
|
662
|
+
const field = intent.field || "objects";
|
|
663
|
+
switch (intent.action) {
|
|
664
|
+
case "aggregate": {
|
|
665
|
+
const d = data;
|
|
666
|
+
const total = d?.total ?? 0;
|
|
667
|
+
const groups = d?.groups?.length ?? 0;
|
|
668
|
+
if (groups > 0) {
|
|
669
|
+
return `${fnName} of ${field} = ${total}, grouped by ${intent.groupBy} into ${groups} groups`;
|
|
670
|
+
}
|
|
671
|
+
return `${fnName} of ${field} = ${total}`;
|
|
672
|
+
}
|
|
673
|
+
case "timeseries": {
|
|
674
|
+
const d = data;
|
|
675
|
+
return `Time series with ${d?.buckets?.length ?? 0} buckets`;
|
|
676
|
+
}
|
|
677
|
+
case "search": {
|
|
678
|
+
const hits = data?.length ?? 0;
|
|
679
|
+
return `Found ${hits} results`;
|
|
680
|
+
}
|
|
681
|
+
default:
|
|
682
|
+
return "Query executed.";
|
|
683
|
+
}
|
|
684
|
+
}
|