robodev 0.36.0 → 0.37.1
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/package.json +3 -3
- package/src/dev-clients.test.ts +38 -0
- package/src/dev-clients.ts +104 -15
- package/src/dev-inspect.test.ts +133 -0
- package/src/dev-inspect.ts +387 -8
- package/src/dev-server.ts +86 -3
- package/src/dev.ts +2 -0
- package/templates/auth-chat/package.json +2 -2
- package/templates/backend/package.json +2 -2
- package/templates/empty/package.json +2 -2
- package/templates/marketplace/package.json +2 -2
- package/templates/space/package.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"embedded-postgres": "17.10.0-beta.17",
|
|
27
27
|
"fastify": "^5.12.1",
|
|
28
28
|
"tsx": "^4.20.5",
|
|
29
|
-
"@robodev-ai/runtime": "0.4.
|
|
30
|
-
"@robodev-ai/sdk": "0.11.
|
|
29
|
+
"@robodev-ai/runtime": "0.4.2",
|
|
30
|
+
"@robodev-ai/sdk": "0.11.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.3.0",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { consoleEmailClient } from "./dev-clients.js";
|
|
7
|
+
import { createDevInspect } from "./dev-inspect.js";
|
|
8
|
+
|
|
9
|
+
test("consoleEmailClient renders a local robodev/emails catalog file", async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "robodev-dev-email-"));
|
|
11
|
+
await mkdir(join(root, "robodev", "emails"), { recursive: true });
|
|
12
|
+
await writeFile(join(root, "robodev", "emails", "welcome.html"), "<p>Hi {{name}}</p>", "utf8");
|
|
13
|
+
await writeFile(
|
|
14
|
+
join(root, "robodev", "emails", "welcome.json"),
|
|
15
|
+
JSON.stringify({ subject: "Hello {{name}}", text: "Hi {{name}}" }),
|
|
16
|
+
"utf8",
|
|
17
|
+
);
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
const inspect = createDevInspect({ max: 20 });
|
|
20
|
+
const email = consoleEmailClient((line) => lines.push(line), inspect, root);
|
|
21
|
+
const result = await email.send({
|
|
22
|
+
to: "ada@example.com",
|
|
23
|
+
template: "welcome",
|
|
24
|
+
vars: { name: "Ada" },
|
|
25
|
+
});
|
|
26
|
+
assert.equal(result.status, "sent");
|
|
27
|
+
assert.match(lines[0] ?? "", /Hello Ada/);
|
|
28
|
+
assert.match(lines[1] ?? "", /Hi Ada/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("consoleEmailClient throws template_not_found without a local file or body", async () => {
|
|
32
|
+
const inspect = createDevInspect({ max: 20 });
|
|
33
|
+
const email = consoleEmailClient(() => undefined, inspect, await mkdtemp(join(tmpdir(), "robodev-dev-email-")));
|
|
34
|
+
await assert.rejects(
|
|
35
|
+
() => email.send({ to: "ada@example.com", template: "missing" }),
|
|
36
|
+
/template_not_found/,
|
|
37
|
+
);
|
|
38
|
+
});
|
package/src/dev-clients.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createLocalStorageClient,
|
|
3
3
|
createProjectSocketsClient,
|
|
4
|
+
loadLocalEmailTemplate,
|
|
5
|
+
resolveEmailSendInput,
|
|
4
6
|
type StorageQueryable,
|
|
5
7
|
} from "@robodev-ai/runtime";
|
|
6
8
|
import type { RouteClients } from "@robodev-ai/runtime";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
9
|
+
import type {
|
|
10
|
+
EmailClient,
|
|
11
|
+
JobEnqueueInput,
|
|
12
|
+
LlmClient,
|
|
13
|
+
SocketsClient,
|
|
14
|
+
StorageClient,
|
|
15
|
+
} from "@robodev-ai/sdk";
|
|
16
|
+
import {
|
|
17
|
+
truncateInspectPreview,
|
|
18
|
+
type DevInspectStore,
|
|
19
|
+
type StorageInspectOp,
|
|
20
|
+
} from "./dev-inspect.js";
|
|
9
21
|
|
|
10
22
|
/**
|
|
11
23
|
* The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
|
|
@@ -21,17 +33,90 @@ function errorMessage(error: unknown): string {
|
|
|
21
33
|
return error instanceof Error ? error.message : String(error);
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
function inspectStorageClient(client: StorageClient, inspect: DevInspectStore): StorageClient {
|
|
37
|
+
async function tracked<T>(op: StorageInspectOp, key: string, run: () => Promise<T>): Promise<T> {
|
|
38
|
+
try {
|
|
39
|
+
const result = await run();
|
|
40
|
+
inspect.record({ kind: "storage", op, key, ok: true });
|
|
41
|
+
return result;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
inspect.record({
|
|
44
|
+
kind: "storage",
|
|
45
|
+
op,
|
|
46
|
+
key,
|
|
47
|
+
ok: false,
|
|
48
|
+
error: errorMessage(error),
|
|
49
|
+
});
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
upload: (key, data, options) => tracked("upload", key, () => client.upload(key, data, options)),
|
|
55
|
+
get: (key) => tracked("get", key, () => client.get(key)),
|
|
56
|
+
getUrl: (key, options) => tracked("getUrl", key, () => client.getUrl(key, options)),
|
|
57
|
+
delete: (key) => tracked("delete", key, () => client.delete(key)),
|
|
58
|
+
list: (options) => tracked("list", options?.prefix ?? "", () => client.list(options)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inspectSocketsClient(client: SocketsClient, inspect: DevInspectStore): SocketsClient {
|
|
63
|
+
function tracked(
|
|
64
|
+
socketKind: "send" | "broadcast",
|
|
65
|
+
name: string,
|
|
66
|
+
payload: unknown,
|
|
67
|
+
run: () => void,
|
|
68
|
+
) {
|
|
69
|
+
try {
|
|
70
|
+
run();
|
|
71
|
+
inspect.record({
|
|
72
|
+
kind: "socket",
|
|
73
|
+
socketKind,
|
|
74
|
+
name,
|
|
75
|
+
preview: truncateInspectPreview(payload),
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
inspect.record({
|
|
79
|
+
kind: "socket",
|
|
80
|
+
socketKind,
|
|
81
|
+
name,
|
|
82
|
+
error: errorMessage(error),
|
|
83
|
+
preview: truncateInspectPreview(payload),
|
|
84
|
+
});
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
send(input) {
|
|
90
|
+
tracked("send", input.name, input.payload, () => client.send(input));
|
|
91
|
+
},
|
|
92
|
+
broadcast(input) {
|
|
93
|
+
tracked("broadcast", input.name, input.payload, () => client.broadcast(input));
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
24
98
|
/** Prints mail to stdout so local signup and reset flows are followable. */
|
|
25
99
|
export function consoleEmailClient(
|
|
26
100
|
log: (line: string) => void,
|
|
27
101
|
inspect: DevInspectStore,
|
|
102
|
+
projectRoot?: string,
|
|
28
103
|
): EmailClient {
|
|
29
104
|
return {
|
|
30
105
|
async send(input) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
106
|
+
const catalog =
|
|
107
|
+
input.template && projectRoot
|
|
108
|
+
? await loadLocalEmailTemplate(projectRoot, input.template)
|
|
109
|
+
: null;
|
|
110
|
+
const resolved = resolveEmailSendInput(input, catalog);
|
|
111
|
+
const to = Array.isArray(resolved.to) ? resolved.to.join(", ") : resolved.to;
|
|
112
|
+
log(`email → ${to}: ${resolved.subject ?? ""}`);
|
|
113
|
+
if (resolved.text) log(` ${resolved.text}`);
|
|
114
|
+
inspect.record({
|
|
115
|
+
kind: "email",
|
|
116
|
+
to,
|
|
117
|
+
subject: resolved.subject ?? "",
|
|
118
|
+
text: resolved.text,
|
|
119
|
+
});
|
|
35
120
|
return { id: `local_${Date.now().toString(36)}`, status: "sent" };
|
|
36
121
|
},
|
|
37
122
|
};
|
|
@@ -54,9 +139,10 @@ export function devRouteClients(input: {
|
|
|
54
139
|
publicBaseUrl: string;
|
|
55
140
|
projectId: string;
|
|
56
141
|
jwtSecret: string;
|
|
142
|
+
projectRoot?: string;
|
|
57
143
|
}): RouteClients {
|
|
58
144
|
return {
|
|
59
|
-
email: consoleEmailClient(input.log, input.inspect),
|
|
145
|
+
email: consoleEmailClient(input.log, input.inspect, input.projectRoot),
|
|
60
146
|
llm: offlineLlmClient(),
|
|
61
147
|
agent: {
|
|
62
148
|
createSession: () => notConfigured("llm_not_configured"),
|
|
@@ -65,13 +151,16 @@ export function devRouteClients(input: {
|
|
|
65
151
|
subscribe: () => notConfigured("llm_not_configured"),
|
|
66
152
|
destroy: () => notConfigured("llm_not_configured"),
|
|
67
153
|
},
|
|
68
|
-
storage:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
154
|
+
storage: inspectStorageClient(
|
|
155
|
+
createLocalStorageClient({
|
|
156
|
+
query: input.query,
|
|
157
|
+
objectsDir: input.objectsDir,
|
|
158
|
+
publicBaseUrl: input.publicBaseUrl,
|
|
159
|
+
projectId: input.projectId,
|
|
160
|
+
jwtSecret: input.jwtSecret,
|
|
161
|
+
}),
|
|
162
|
+
input.inspect,
|
|
163
|
+
),
|
|
75
164
|
push: { send: () => notConfigured("push_not_configured") },
|
|
76
165
|
jobs: {
|
|
77
166
|
enqueue: async (job) => {
|
|
@@ -96,7 +185,7 @@ export function devRouteClients(input: {
|
|
|
96
185
|
}
|
|
97
186
|
},
|
|
98
187
|
},
|
|
99
|
-
sockets: createProjectSocketsClient(input.projectId),
|
|
188
|
+
sockets: inspectSocketsClient(createProjectSocketsClient(input.projectId), input.inspect),
|
|
100
189
|
env: input.env,
|
|
101
190
|
};
|
|
102
191
|
}
|
package/src/dev-inspect.test.ts
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
isHistogramExcludedPath,
|
|
9
9
|
isInspectPath,
|
|
10
10
|
shouldCountRequestHistogram,
|
|
11
|
+
socketEventMatchesDef,
|
|
12
|
+
storageActivityMatchesKey,
|
|
13
|
+
truncateInspectPreview,
|
|
11
14
|
type DevInspectAuthCodeEvent,
|
|
12
15
|
type DevInspectEmailEvent,
|
|
13
16
|
type DevInspectJobEvent,
|
|
@@ -25,7 +28,10 @@ test("isInspectPath covers dashboard pages and JSON only", () => {
|
|
|
25
28
|
assert.equal(isInspectPath("/_robodev/jobs"), true);
|
|
26
29
|
assert.equal(isInspectPath("/_robodev/emails"), true);
|
|
27
30
|
assert.equal(isInspectPath("/_robodev/auth"), true);
|
|
31
|
+
assert.equal(isInspectPath("/_robodev/storage"), true);
|
|
32
|
+
assert.equal(isInspectPath("/_robodev/sockets"), true);
|
|
28
33
|
assert.equal(isInspectPath("/_robodev/logs"), true);
|
|
34
|
+
assert.equal(isInspectPath("/storage/objects/avatar.png"), false);
|
|
29
35
|
assert.equal(isInspectPath("/_robodev/state.json"), true);
|
|
30
36
|
assert.equal(isInspectPath("/_robodev/logs.json"), true);
|
|
31
37
|
assert.equal(isInspectPath("/_robodev/docs"), false);
|
|
@@ -43,6 +49,9 @@ test("shouldCountRequestHistogram counts Auth and matched file routes only", ()
|
|
|
43
49
|
assert.equal(shouldCountRequestHistogram("/api/unknown", fileRuntime, "GET"), false);
|
|
44
50
|
assert.equal(shouldCountRequestHistogram("/_robodev", fileRuntime, "GET"), false);
|
|
45
51
|
assert.equal(shouldCountRequestHistogram("/_robodev/apis", fileRuntime, "GET"), false);
|
|
52
|
+
assert.equal(shouldCountRequestHistogram("/_robodev/storage", fileRuntime, "GET"), false);
|
|
53
|
+
assert.equal(shouldCountRequestHistogram("/_robodev/sockets", fileRuntime, "GET"), false);
|
|
54
|
+
assert.equal(shouldCountRequestHistogram("/storage/objects/a.png", fileRuntime, "GET"), false);
|
|
46
55
|
assert.equal(shouldCountRequestHistogram("/_robodev/docs", fileRuntime, "GET"), false);
|
|
47
56
|
assert.equal(shouldCountRequestHistogram("/_robodev/scalar", fileRuntime, "GET"), false);
|
|
48
57
|
assert.equal(shouldCountRequestHistogram("/_robodev/ready", fileRuntime, "GET"), false);
|
|
@@ -223,6 +232,10 @@ test("dashboard snapshot lists reserved Auth, filters emails, and ignores enqueu
|
|
|
223
232
|
assert.equal(state.jobRuns[0]?.status, "failed");
|
|
224
233
|
assert.equal(state.overview.jobHistogram.at(-1)?.failed, 1);
|
|
225
234
|
assert.deepEqual(state.auth.users, []);
|
|
235
|
+
assert.equal(state.overview.storageObjectCount, 0);
|
|
236
|
+
assert.equal(state.overview.socketCount, 0);
|
|
237
|
+
assert.deepEqual(state.storage, { objects: [], activity: [] });
|
|
238
|
+
assert.deepEqual(state.sockets, { connected: 0, defs: [], events: [] });
|
|
226
239
|
});
|
|
227
240
|
|
|
228
241
|
test("incomplete Google credentials are not labeled override", async () => {
|
|
@@ -242,6 +255,10 @@ test("inspect HTML has nav labels and polls state.json", () => {
|
|
|
242
255
|
assert.match(html, />Jobs</);
|
|
243
256
|
assert.match(html, />Emails</);
|
|
244
257
|
assert.match(html, />Auth</);
|
|
258
|
+
assert.match(html, />Storage</);
|
|
259
|
+
assert.match(html, />Sockets</);
|
|
260
|
+
assert.match(html, /\/_robodev\/storage/);
|
|
261
|
+
assert.match(html, /\/_robodev\/sockets/);
|
|
245
262
|
assert.doesNotMatch(html, /<h2>Requests<\/h2>/);
|
|
246
263
|
assert.doesNotMatch(html, /<h2>Emails<\/h2>/);
|
|
247
264
|
assert.doesNotMatch(html, /<h2>Jobs<\/h2>/);
|
|
@@ -250,3 +267,119 @@ test("inspect HTML has nav labels and polls state.json", () => {
|
|
|
250
267
|
assert.doesNotMatch(html, /fetch\("\/_robodev\/logs\.json"\)/);
|
|
251
268
|
assert.match(html, /setInterval\(poll, 1000\)/);
|
|
252
269
|
});
|
|
270
|
+
|
|
271
|
+
test("storageActivityMatchesKey uses exact key except list prefixes", () => {
|
|
272
|
+
assert.equal(storageActivityMatchesKey({ op: "upload", key: "a/b" }, "a/b"), true);
|
|
273
|
+
assert.equal(storageActivityMatchesKey({ op: "get", key: "a/b" }, "a/c"), false);
|
|
274
|
+
assert.equal(storageActivityMatchesKey({ op: "getUrl", key: "a/b" }, "a/b"), true);
|
|
275
|
+
assert.equal(storageActivityMatchesKey({ op: "delete", key: "a/b" }, "a/b"), true);
|
|
276
|
+
assert.equal(storageActivityMatchesKey({ op: "serve", key: "a/b" }, "a/b"), true);
|
|
277
|
+
assert.equal(storageActivityMatchesKey({ op: "list", key: "" }, "a/b"), true);
|
|
278
|
+
assert.equal(storageActivityMatchesKey({ op: "list", key: "a/" }, "a/b"), true);
|
|
279
|
+
assert.equal(storageActivityMatchesKey({ op: "list", key: "a/" }, "a/"), true);
|
|
280
|
+
assert.equal(storageActivityMatchesKey({ op: "list", key: "b" }, "a/b"), false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("socketEventMatchesDef matches path params or name", () => {
|
|
284
|
+
const chat = { path: "/sockets/chat", name: "chat" };
|
|
285
|
+
const room = { path: "/sockets/room/:id", name: "room" };
|
|
286
|
+
assert.equal(socketEventMatchesDef({ path: "/sockets/chat", name: "chat" }, chat), true);
|
|
287
|
+
assert.equal(socketEventMatchesDef({ name: "chat" }, chat), true);
|
|
288
|
+
assert.equal(socketEventMatchesDef({ path: "/sockets/other" }, chat), false);
|
|
289
|
+
assert.equal(socketEventMatchesDef({ path: "/sockets/room/abc" }, room), true);
|
|
290
|
+
assert.equal(socketEventMatchesDef({ name: "room" }, room), true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("truncateInspectPreview caps text and JSON at 200", () => {
|
|
294
|
+
assert.equal(truncateInspectPreview("short"), "short");
|
|
295
|
+
assert.equal(truncateInspectPreview({ a: 1 }), '{"a":1}');
|
|
296
|
+
const long = "x".repeat(220);
|
|
297
|
+
const preview = truncateInspectPreview(long);
|
|
298
|
+
assert.equal(preview.length, 201);
|
|
299
|
+
assert.equal(preview.endsWith("…"), true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("dashboard snapshot includes storage objects, activity, and socket defs", async () => {
|
|
303
|
+
const inspect = createDevInspect();
|
|
304
|
+
inspect.record({ kind: "storage", op: "upload", key: "a.png", ok: true });
|
|
305
|
+
inspect.record({ kind: "storage", op: "list", key: "", ok: true });
|
|
306
|
+
inspect.record({ kind: "storage", op: "serve", key: "missing", ok: false, error: "not_found" });
|
|
307
|
+
inspect.record({
|
|
308
|
+
kind: "socket",
|
|
309
|
+
socketKind: "connect",
|
|
310
|
+
path: "/sockets/chat",
|
|
311
|
+
name: "chat",
|
|
312
|
+
userId: "u1",
|
|
313
|
+
});
|
|
314
|
+
inspect.record({
|
|
315
|
+
kind: "socket",
|
|
316
|
+
socketKind: "send",
|
|
317
|
+
name: "chat",
|
|
318
|
+
preview: '{"hi":1}',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const pool = {
|
|
322
|
+
query: async <R extends Record<string, unknown>>(sql: string) => {
|
|
323
|
+
if (sql.includes("robodev_auth.users")) return { rows: [] as unknown as R[] };
|
|
324
|
+
if (sql.includes("count(*)") && sql.includes("robodev_storage.objects")) {
|
|
325
|
+
return { rows: [{ count: "2" }] as unknown as R[] };
|
|
326
|
+
}
|
|
327
|
+
if (sql.includes("robodev_storage.objects")) {
|
|
328
|
+
return {
|
|
329
|
+
rows: [
|
|
330
|
+
{
|
|
331
|
+
key: "a.png",
|
|
332
|
+
public: true,
|
|
333
|
+
size_bytes: 12,
|
|
334
|
+
content_type: "image/png",
|
|
335
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
336
|
+
},
|
|
337
|
+
] as unknown as R[],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return { rows: [] as unknown as R[] };
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const state = await buildDashboardState({
|
|
345
|
+
inspect,
|
|
346
|
+
projectId: "local",
|
|
347
|
+
runtime: {
|
|
348
|
+
routes: [],
|
|
349
|
+
jobs: [],
|
|
350
|
+
sockets: [
|
|
351
|
+
{ name: "chat", path: "/sockets/chat", file: "sockets/chat.ts", def: { auth: false } },
|
|
352
|
+
],
|
|
353
|
+
pool,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
assert.equal(state.overview.storageObjectCount, 2);
|
|
358
|
+
assert.equal(state.overview.socketCount, 1);
|
|
359
|
+
assert.equal(state.storage.objects.length, 1);
|
|
360
|
+
assert.equal(state.storage.objects[0]?.url, "/storage/objects/a.png");
|
|
361
|
+
assert.equal(state.storage.objects[0]?.sizeBytes, 12);
|
|
362
|
+
assert.equal(state.storage.activity.length, 3);
|
|
363
|
+
assert.equal(state.storage.activity[0]?.op, "serve");
|
|
364
|
+
assert.equal(state.sockets.defs[0]?.auth, "public");
|
|
365
|
+
assert.equal(state.sockets.connected, 0);
|
|
366
|
+
assert.equal(state.sockets.events.length, 2);
|
|
367
|
+
assert.equal(state.sockets.events[0]?.socketKind, "send");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("storage object count is 0 when the query fails", async () => {
|
|
371
|
+
const state = await buildDashboardState({
|
|
372
|
+
inspect: createDevInspect(),
|
|
373
|
+
runtime: {
|
|
374
|
+
routes: [],
|
|
375
|
+
jobs: [],
|
|
376
|
+
pool: {
|
|
377
|
+
query: async () => {
|
|
378
|
+
throw new Error("missing schema");
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
assert.equal(state.overview.storageObjectCount, 0);
|
|
384
|
+
assert.deepEqual(state.storage.objects, []);
|
|
385
|
+
});
|
package/src/dev-inspect.ts
CHANGED
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
matchPathParams,
|
|
4
4
|
matchProjectRoute,
|
|
5
5
|
projectGoogleOverrideMode,
|
|
6
|
+
projectSocketCount,
|
|
7
|
+
socketAuthDisplay,
|
|
6
8
|
} from "@robodev-ai/runtime";
|
|
9
|
+
import type { ApiAuth } from "@robodev-ai/sdk";
|
|
7
10
|
|
|
8
11
|
export const DEV_INSPECT_MAX = 500;
|
|
9
12
|
export const HISTOGRAM_MINUTES = 120;
|
|
@@ -14,6 +17,8 @@ const DASHBOARD_HTML_PATHS = new Set([
|
|
|
14
17
|
"/_robodev/jobs",
|
|
15
18
|
"/_robodev/emails",
|
|
16
19
|
"/_robodev/auth",
|
|
20
|
+
"/_robodev/storage",
|
|
21
|
+
"/_robodev/sockets",
|
|
17
22
|
"/_robodev/logs",
|
|
18
23
|
]);
|
|
19
24
|
|
|
@@ -47,7 +52,13 @@ export const RESERVED_AUTH_ROUTES: Array<{ method: string; path: string; file: s
|
|
|
47
52
|
{ method: "PUT", path: "/api/user/me", file: "reserved" },
|
|
48
53
|
];
|
|
49
54
|
|
|
50
|
-
export
|
|
55
|
+
export const SOCKET_PREVIEW_MAX = 200;
|
|
56
|
+
|
|
57
|
+
export type DevInspectKind =
|
|
58
|
+
"request" | "email" | "auth_code" | "job" | "job_run" | "log" | "storage" | "socket";
|
|
59
|
+
|
|
60
|
+
export type StorageInspectOp = "upload" | "get" | "getUrl" | "delete" | "list" | "serve";
|
|
61
|
+
export type SocketInspectKind = "connect" | "message" | "close" | "send" | "broadcast";
|
|
51
62
|
|
|
52
63
|
type EventBase = {
|
|
53
64
|
id: string;
|
|
@@ -96,13 +107,35 @@ export type DevInspectLogEvent = EventBase & {
|
|
|
96
107
|
message: string;
|
|
97
108
|
};
|
|
98
109
|
|
|
110
|
+
export type DevInspectStorageEvent = EventBase & {
|
|
111
|
+
kind: "storage";
|
|
112
|
+
op: StorageInspectOp;
|
|
113
|
+
key: string;
|
|
114
|
+
ok: boolean;
|
|
115
|
+
error?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type DevInspectSocketEvent = EventBase & {
|
|
119
|
+
kind: "socket";
|
|
120
|
+
socketKind: SocketInspectKind;
|
|
121
|
+
path?: string;
|
|
122
|
+
name?: string;
|
|
123
|
+
userId?: string | null;
|
|
124
|
+
code?: number;
|
|
125
|
+
reason?: string;
|
|
126
|
+
error?: string;
|
|
127
|
+
preview?: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
99
130
|
export type DevInspectEvent =
|
|
100
131
|
| DevInspectRequestEvent
|
|
101
132
|
| DevInspectEmailEvent
|
|
102
133
|
| DevInspectAuthCodeEvent
|
|
103
134
|
| DevInspectJobEvent
|
|
104
135
|
| DevInspectJobRunEvent
|
|
105
|
-
| DevInspectLogEvent
|
|
136
|
+
| DevInspectLogEvent
|
|
137
|
+
| DevInspectStorageEvent
|
|
138
|
+
| DevInspectSocketEvent;
|
|
106
139
|
|
|
107
140
|
export type DevInspectRecordInput =
|
|
108
141
|
| Omit<DevInspectRequestEvent, "id" | "at">
|
|
@@ -110,17 +143,57 @@ export type DevInspectRecordInput =
|
|
|
110
143
|
| Omit<DevInspectAuthCodeEvent, "id" | "at">
|
|
111
144
|
| Omit<DevInspectJobEvent, "id" | "at">
|
|
112
145
|
| Omit<DevInspectJobRunEvent, "id" | "at">
|
|
113
|
-
| Omit<DevInspectLogEvent, "id" | "at"
|
|
146
|
+
| Omit<DevInspectLogEvent, "id" | "at">
|
|
147
|
+
| Omit<DevInspectStorageEvent, "id" | "at">
|
|
148
|
+
| Omit<DevInspectSocketEvent, "id" | "at">;
|
|
114
149
|
|
|
115
150
|
export type DashboardGoogle = "broker" | "local_override" | "incomplete";
|
|
116
151
|
|
|
117
152
|
export type RequestHistogramBucket = { t: number; c2xx: number; c4xx: number; c5xx: number };
|
|
118
153
|
export type JobHistogramBucket = { t: number; succeeded: number; failed: number };
|
|
119
154
|
|
|
155
|
+
export type DashboardStorageObject = {
|
|
156
|
+
key: string;
|
|
157
|
+
public: boolean;
|
|
158
|
+
sizeBytes: number;
|
|
159
|
+
contentType: string;
|
|
160
|
+
createdAt: string;
|
|
161
|
+
url: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type DashboardStorageActivity = {
|
|
165
|
+
at: number;
|
|
166
|
+
op: StorageInspectOp;
|
|
167
|
+
key: string;
|
|
168
|
+
ok: boolean;
|
|
169
|
+
error?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type DashboardSocketDef = {
|
|
173
|
+
path: string;
|
|
174
|
+
file: string;
|
|
175
|
+
auth: "required" | "public";
|
|
176
|
+
name: string;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export type DashboardSocketEvent = {
|
|
180
|
+
at: number;
|
|
181
|
+
socketKind: SocketInspectKind;
|
|
182
|
+
path?: string;
|
|
183
|
+
name?: string;
|
|
184
|
+
userId?: string | null;
|
|
185
|
+
code?: number;
|
|
186
|
+
reason?: string;
|
|
187
|
+
error?: string;
|
|
188
|
+
preview?: string;
|
|
189
|
+
};
|
|
190
|
+
|
|
120
191
|
export type DashboardState = {
|
|
121
192
|
overview: {
|
|
122
193
|
apiCount: number;
|
|
123
194
|
jobCount: number;
|
|
195
|
+
storageObjectCount: number;
|
|
196
|
+
socketCount: number;
|
|
124
197
|
google: DashboardGoogle;
|
|
125
198
|
requestHistogram: RequestHistogramBucket[];
|
|
126
199
|
jobHistogram: JobHistogramBucket[];
|
|
@@ -141,11 +214,21 @@ export type DashboardState = {
|
|
|
141
214
|
google: DashboardGoogle;
|
|
142
215
|
users: Array<{ id: string; email: string; name: string | null; createdAt: string }>;
|
|
143
216
|
};
|
|
217
|
+
storage: {
|
|
218
|
+
objects: DashboardStorageObject[];
|
|
219
|
+
activity: DashboardStorageActivity[];
|
|
220
|
+
};
|
|
221
|
+
sockets: {
|
|
222
|
+
connected: number;
|
|
223
|
+
defs: DashboardSocketDef[];
|
|
224
|
+
events: DashboardSocketEvent[];
|
|
225
|
+
};
|
|
144
226
|
};
|
|
145
227
|
|
|
146
228
|
export type DashboardRuntime = {
|
|
147
229
|
routes: Array<{ method: string; path: string; file: string }>;
|
|
148
230
|
jobs: Array<{ name: string; file: string; def: { cron?: string } }>;
|
|
231
|
+
sockets?: Array<{ name: string; path: string; file: string; def: { auth?: ApiAuth } }>;
|
|
149
232
|
pool?: {
|
|
150
233
|
query: <R extends Record<string, unknown> = Record<string, unknown>>(
|
|
151
234
|
sql: string,
|
|
@@ -196,6 +279,41 @@ export function callMatchesRoute(
|
|
|
196
279
|
return matchPathParams(prefixed, call.path) !== null;
|
|
197
280
|
}
|
|
198
281
|
|
|
282
|
+
/** Exact key for object ops; list matches empty prefix, the prefix itself, or a key under it. */
|
|
283
|
+
export function storageActivityMatchesKey(
|
|
284
|
+
event: { op: StorageInspectOp; key: string },
|
|
285
|
+
selectedKey: string,
|
|
286
|
+
): boolean {
|
|
287
|
+
if (event.op === "list") {
|
|
288
|
+
const prefix = event.key;
|
|
289
|
+
return prefix === "" || selectedKey === prefix || selectedKey.startsWith(prefix);
|
|
290
|
+
}
|
|
291
|
+
return event.key === selectedKey;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function socketEventMatchesDef(
|
|
295
|
+
event: { path?: string; name?: string },
|
|
296
|
+
socket: { path: string; name: string },
|
|
297
|
+
): boolean {
|
|
298
|
+
if (event.path && matchPathParams(socket.path, event.path)) return true;
|
|
299
|
+
return event.name === socket.name;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function truncateInspectPreview(value: unknown, max = SOCKET_PREVIEW_MAX): string {
|
|
303
|
+
let text: string;
|
|
304
|
+
if (typeof value === "string") {
|
|
305
|
+
text = value;
|
|
306
|
+
} else {
|
|
307
|
+
try {
|
|
308
|
+
text = JSON.stringify(value) ?? "";
|
|
309
|
+
} catch {
|
|
310
|
+
text = "[unserializable]";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (text.length <= max) return text;
|
|
314
|
+
return `${text.slice(0, max)}…`;
|
|
315
|
+
}
|
|
316
|
+
|
|
199
317
|
export function googleDashboardStatus(mode: "both" | "one" | "none"): DashboardGoogle {
|
|
200
318
|
if (mode === "both") return "local_override";
|
|
201
319
|
if (mode === "one") return "incomplete";
|
|
@@ -305,6 +423,11 @@ export function createDevInspect(options?: { max?: number }): DevInspectStore {
|
|
|
305
423
|
};
|
|
306
424
|
}
|
|
307
425
|
|
|
426
|
+
function isoTimestamp(value: Date | string | undefined): string {
|
|
427
|
+
if (value instanceof Date) return value.toISOString();
|
|
428
|
+
return String(value ?? "");
|
|
429
|
+
}
|
|
430
|
+
|
|
308
431
|
async function loadAuthUsers(
|
|
309
432
|
pool: NonNullable<DashboardRuntime>["pool"],
|
|
310
433
|
): Promise<DashboardState["auth"]["users"]> {
|
|
@@ -320,10 +443,50 @@ async function loadAuthUsers(
|
|
|
320
443
|
id: row.id,
|
|
321
444
|
email: row.email,
|
|
322
445
|
name: row.name ?? null,
|
|
323
|
-
createdAt:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
446
|
+
createdAt: isoTimestamp(row.created_at),
|
|
447
|
+
}));
|
|
448
|
+
} catch {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function loadStorageObjectCount(
|
|
454
|
+
pool: NonNullable<DashboardRuntime>["pool"],
|
|
455
|
+
): Promise<number> {
|
|
456
|
+
if (!pool) return 0;
|
|
457
|
+
try {
|
|
458
|
+
const result = await pool.query<{ count: string | number }>(
|
|
459
|
+
`SELECT count(*) FROM robodev_storage.objects`,
|
|
460
|
+
);
|
|
461
|
+
return Number(result.rows[0]?.count ?? 0);
|
|
462
|
+
} catch {
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function loadStorageObjects(
|
|
468
|
+
pool: NonNullable<DashboardRuntime>["pool"],
|
|
469
|
+
): Promise<DashboardStorageObject[]> {
|
|
470
|
+
if (!pool) return [];
|
|
471
|
+
try {
|
|
472
|
+
const result = await pool.query<{
|
|
473
|
+
key: string;
|
|
474
|
+
public: boolean;
|
|
475
|
+
size_bytes: string | number;
|
|
476
|
+
content_type: string;
|
|
477
|
+
created_at: Date | string;
|
|
478
|
+
}>(
|
|
479
|
+
`SELECT key, public, size_bytes, content_type, created_at
|
|
480
|
+
FROM robodev_storage.objects
|
|
481
|
+
ORDER BY created_at DESC`,
|
|
482
|
+
);
|
|
483
|
+
return result.rows.map((row) => ({
|
|
484
|
+
key: row.key,
|
|
485
|
+
public: Boolean(row.public),
|
|
486
|
+
sizeBytes: Number(row.size_bytes),
|
|
487
|
+
contentType: row.content_type,
|
|
488
|
+
createdAt: isoTimestamp(row.created_at),
|
|
489
|
+
url: `/storage/objects/${row.key}`,
|
|
327
490
|
}));
|
|
328
491
|
} catch {
|
|
329
492
|
return [];
|
|
@@ -333,6 +496,7 @@ async function loadAuthUsers(
|
|
|
333
496
|
export async function buildDashboardState(input: {
|
|
334
497
|
inspect: DevInspectStore;
|
|
335
498
|
runtime: DashboardRuntime;
|
|
499
|
+
projectId?: string;
|
|
336
500
|
googleClientId?: string;
|
|
337
501
|
googleClientSecret?: string;
|
|
338
502
|
}): Promise<DashboardState> {
|
|
@@ -378,11 +542,41 @@ export async function buildDashboardState(input: {
|
|
|
378
542
|
cron: job.def.cron ?? null,
|
|
379
543
|
file: job.file,
|
|
380
544
|
}));
|
|
545
|
+
const activity = newest
|
|
546
|
+
.filter((event): event is DevInspectStorageEvent => event.kind === "storage")
|
|
547
|
+
.map((event) => ({
|
|
548
|
+
at: event.at,
|
|
549
|
+
op: event.op,
|
|
550
|
+
key: event.key,
|
|
551
|
+
ok: event.ok,
|
|
552
|
+
error: event.error,
|
|
553
|
+
}));
|
|
554
|
+
const socketEvents = newest
|
|
555
|
+
.filter((event): event is DevInspectSocketEvent => event.kind === "socket")
|
|
556
|
+
.map((event) => ({
|
|
557
|
+
at: event.at,
|
|
558
|
+
socketKind: event.socketKind,
|
|
559
|
+
path: event.path,
|
|
560
|
+
name: event.name,
|
|
561
|
+
userId: event.userId,
|
|
562
|
+
code: event.code,
|
|
563
|
+
reason: event.reason,
|
|
564
|
+
error: event.error,
|
|
565
|
+
preview: event.preview,
|
|
566
|
+
}));
|
|
567
|
+
const socketDefs = (input.runtime?.sockets ?? []).map((socket) => ({
|
|
568
|
+
path: socket.path,
|
|
569
|
+
file: socket.file,
|
|
570
|
+
auth: socketAuthDisplay(socket.def.auth ?? false),
|
|
571
|
+
name: socket.name,
|
|
572
|
+
}));
|
|
381
573
|
|
|
382
574
|
return {
|
|
383
575
|
overview: {
|
|
384
576
|
apiCount: routes.length,
|
|
385
577
|
jobCount: jobs.length,
|
|
578
|
+
storageObjectCount: await loadStorageObjectCount(input.runtime?.pool),
|
|
579
|
+
socketCount: socketDefs.length,
|
|
386
580
|
google,
|
|
387
581
|
requestHistogram: input.inspect.requestHistogram(),
|
|
388
582
|
jobHistogram: input.inspect.jobHistogram(),
|
|
@@ -397,6 +591,15 @@ export async function buildDashboardState(input: {
|
|
|
397
591
|
google,
|
|
398
592
|
users: await loadAuthUsers(input.runtime?.pool),
|
|
399
593
|
},
|
|
594
|
+
storage: {
|
|
595
|
+
objects: await loadStorageObjects(input.runtime?.pool),
|
|
596
|
+
activity,
|
|
597
|
+
},
|
|
598
|
+
sockets: {
|
|
599
|
+
connected: input.projectId ? projectSocketCount(input.projectId) : 0,
|
|
600
|
+
defs: socketDefs,
|
|
601
|
+
events: socketEvents,
|
|
602
|
+
},
|
|
400
603
|
};
|
|
401
604
|
}
|
|
402
605
|
|
|
@@ -488,6 +691,8 @@ export function inspectHtml(): string {
|
|
|
488
691
|
<a href="/_robodev/jobs" data-nav="jobs">Jobs</a>
|
|
489
692
|
<a href="/_robodev/emails" data-nav="emails">Emails</a>
|
|
490
693
|
<a href="/_robodev/auth" data-nav="auth">Auth</a>
|
|
694
|
+
<a href="/_robodev/storage" data-nav="storage">Storage</a>
|
|
695
|
+
<a href="/_robodev/sockets" data-nav="sockets">Sockets</a>
|
|
491
696
|
</nav>
|
|
492
697
|
</header>
|
|
493
698
|
<p class="lead">Local dashboard. Readonly. Polls every 1s.</p>
|
|
@@ -496,6 +701,8 @@ export function inspectHtml(): string {
|
|
|
496
701
|
<div class="cards">
|
|
497
702
|
<div class="card"><div class="label">APIs</div><div class="value" id="api-count">0</div></div>
|
|
498
703
|
<div class="card"><div class="label">Jobs</div><div class="value" id="job-count">0</div></div>
|
|
704
|
+
<div class="card"><div class="label">Storage</div><div class="value" id="storage-count">0</div></div>
|
|
705
|
+
<div class="card"><div class="label">Sockets</div><div class="value" id="socket-count">0</div></div>
|
|
499
706
|
<div class="card"><div class="label">Auth</div><div class="note" id="google-line"></div></div>
|
|
500
707
|
</div>
|
|
501
708
|
<div class="links" id="doc-links"></div>
|
|
@@ -544,16 +751,43 @@ export function inspectHtml(): string {
|
|
|
544
751
|
<div id="auth-users"></div>
|
|
545
752
|
</section>
|
|
546
753
|
|
|
754
|
+
<section id="page-storage" class="page">
|
|
755
|
+
<div class="tabs">
|
|
756
|
+
<button type="button" data-storage-tab="objects">Objects</button>
|
|
757
|
+
<button type="button" data-storage-tab="activity">Activity</button>
|
|
758
|
+
</div>
|
|
759
|
+
<div id="storage-filter" class="filter" hidden></div>
|
|
760
|
+
<div id="storage-objects"></div>
|
|
761
|
+
<div id="storage-activity" hidden></div>
|
|
762
|
+
</section>
|
|
763
|
+
|
|
764
|
+
<section id="page-sockets" class="page">
|
|
765
|
+
<p class="lead" id="sockets-connected">0 connected</p>
|
|
766
|
+
<div class="tabs">
|
|
767
|
+
<button type="button" data-socket-tab="sockets">Sockets</button>
|
|
768
|
+
<button type="button" data-socket-tab="events">Events</button>
|
|
769
|
+
</div>
|
|
770
|
+
<div id="sockets-filter" class="filter" hidden></div>
|
|
771
|
+
<div id="sockets-defs"></div>
|
|
772
|
+
<div id="sockets-events" hidden></div>
|
|
773
|
+
</section>
|
|
774
|
+
|
|
547
775
|
<script>
|
|
548
776
|
var PAGES = {
|
|
549
777
|
"/_robodev": "overview",
|
|
550
778
|
"/_robodev/apis": "apis",
|
|
551
779
|
"/_robodev/jobs": "jobs",
|
|
552
780
|
"/_robodev/emails": "emails",
|
|
553
|
-
"/_robodev/auth": "auth"
|
|
781
|
+
"/_robodev/auth": "auth",
|
|
782
|
+
"/_robodev/storage": "storage",
|
|
783
|
+
"/_robodev/sockets": "sockets"
|
|
554
784
|
};
|
|
555
785
|
var apisTab = "routes";
|
|
786
|
+
var storageTab = "objects";
|
|
787
|
+
var socketTab = "sockets";
|
|
556
788
|
var callFilter = null;
|
|
789
|
+
var storageFilter = null;
|
|
790
|
+
var socketFilter = null;
|
|
557
791
|
var lastState = null;
|
|
558
792
|
|
|
559
793
|
function pagePath() {
|
|
@@ -586,6 +820,17 @@ export function inspectHtml(): string {
|
|
|
586
820
|
var prefixed = route.path === "/" ? "/api" : "/api" + route.path;
|
|
587
821
|
return matchPathParams(prefixed, call.path) !== null;
|
|
588
822
|
}
|
|
823
|
+
function storageActivityMatches(event, selectedKey) {
|
|
824
|
+
if (event.op === "list") {
|
|
825
|
+
var prefix = event.key;
|
|
826
|
+
return prefix === "" || selectedKey === prefix || selectedKey.indexOf(prefix) === 0;
|
|
827
|
+
}
|
|
828
|
+
return event.key === selectedKey;
|
|
829
|
+
}
|
|
830
|
+
function socketEventMatches(event, socket) {
|
|
831
|
+
if (event.path && matchPathParams(socket.path, event.path)) return true;
|
|
832
|
+
return event.name === socket.name;
|
|
833
|
+
}
|
|
589
834
|
function timeLabel(at) {
|
|
590
835
|
return new Date(at).toLocaleTimeString();
|
|
591
836
|
}
|
|
@@ -682,11 +927,45 @@ export function inspectHtml(): string {
|
|
|
682
927
|
document.getElementById("apis-routes").hidden = tab !== "routes";
|
|
683
928
|
document.getElementById("apis-calls").hidden = tab !== "calls";
|
|
684
929
|
}
|
|
930
|
+
function setStorageTab(tab) {
|
|
931
|
+
storageTab = tab;
|
|
932
|
+
document.querySelector('[data-storage-tab="objects"]').className = tab === "objects" ? "active" : "";
|
|
933
|
+
document.querySelector('[data-storage-tab="activity"]').className = tab === "activity" ? "active" : "";
|
|
934
|
+
document.getElementById("storage-objects").hidden = tab !== "objects";
|
|
935
|
+
document.getElementById("storage-activity").hidden = tab !== "activity";
|
|
936
|
+
}
|
|
937
|
+
function setSocketTab(tab) {
|
|
938
|
+
socketTab = tab;
|
|
939
|
+
document.querySelector('[data-socket-tab="sockets"]').className = tab === "sockets" ? "active" : "";
|
|
940
|
+
document.querySelector('[data-socket-tab="events"]').className = tab === "events" ? "active" : "";
|
|
941
|
+
document.getElementById("sockets-defs").hidden = tab !== "sockets";
|
|
942
|
+
document.getElementById("sockets-events").hidden = tab !== "events";
|
|
943
|
+
}
|
|
944
|
+
function renderFilter(el, labelText, onClear) {
|
|
945
|
+
if (!labelText) {
|
|
946
|
+
el.hidden = true;
|
|
947
|
+
el.replaceChildren();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
el.hidden = false;
|
|
951
|
+
el.replaceChildren();
|
|
952
|
+
var label = document.createElement("span");
|
|
953
|
+
label.textContent = labelText;
|
|
954
|
+
var clear = document.createElement("button");
|
|
955
|
+
clear.type = "button";
|
|
956
|
+
clear.className = "clear";
|
|
957
|
+
clear.textContent = "Clear filter";
|
|
958
|
+
clear.addEventListener("click", onClear);
|
|
959
|
+
el.appendChild(label);
|
|
960
|
+
el.appendChild(clear);
|
|
961
|
+
}
|
|
685
962
|
function render(state) {
|
|
686
963
|
lastState = state;
|
|
687
964
|
markNav();
|
|
688
965
|
document.getElementById("api-count").textContent = String(state.overview.apiCount);
|
|
689
966
|
document.getElementById("job-count").textContent = String(state.overview.jobCount);
|
|
967
|
+
document.getElementById("storage-count").textContent = String(state.overview.storageObjectCount);
|
|
968
|
+
document.getElementById("socket-count").textContent = String(state.overview.socketCount);
|
|
690
969
|
document.getElementById("google-line").textContent = googleLabel(state.overview.google);
|
|
691
970
|
var links = document.getElementById("doc-links");
|
|
692
971
|
links.replaceChildren();
|
|
@@ -784,6 +1063,88 @@ export function inspectHtml(): string {
|
|
|
784
1063
|
return { cells: [user.id, user.email, user.name || "", user.createdAt] };
|
|
785
1064
|
})
|
|
786
1065
|
));
|
|
1066
|
+
|
|
1067
|
+
fill(document.getElementById("storage-objects"), table(
|
|
1068
|
+
["Key", "Public", "Size", "Type", "Created", "URL"],
|
|
1069
|
+
state.storage.objects.map(function (object) {
|
|
1070
|
+
return { cells: [object.key, object.public ? "public" : "private", object.sizeBytes, object.contentType, object.createdAt, object.url], object: object };
|
|
1071
|
+
}),
|
|
1072
|
+
function (row) {
|
|
1073
|
+
storageFilter = row.object.key;
|
|
1074
|
+
setStorageTab("activity");
|
|
1075
|
+
render(lastState);
|
|
1076
|
+
}
|
|
1077
|
+
));
|
|
1078
|
+
var storageEvents = state.storage.activity;
|
|
1079
|
+
if (storageFilter) {
|
|
1080
|
+
storageEvents = state.storage.activity.filter(function (event) {
|
|
1081
|
+
return storageActivityMatches(event, storageFilter);
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
renderFilter(
|
|
1085
|
+
document.getElementById("storage-filter"),
|
|
1086
|
+
storageFilter ? "Filtered to " + storageFilter : null,
|
|
1087
|
+
function () {
|
|
1088
|
+
storageFilter = null;
|
|
1089
|
+
render(lastState);
|
|
1090
|
+
}
|
|
1091
|
+
);
|
|
1092
|
+
fill(document.getElementById("storage-activity"), table(
|
|
1093
|
+
["Time", "Op", "Key", "Status"],
|
|
1094
|
+
storageEvents.map(function (event) {
|
|
1095
|
+
var status = event.ok
|
|
1096
|
+
? { html: '<span class="ok-text">ok</span>' }
|
|
1097
|
+
: { html: '<span class="bad-text">' + (event.error || "error") + "</span>" };
|
|
1098
|
+
return { cells: [timeLabel(event.at), event.op, event.key, status] };
|
|
1099
|
+
})
|
|
1100
|
+
));
|
|
1101
|
+
setStorageTab(storageTab);
|
|
1102
|
+
|
|
1103
|
+
document.getElementById("sockets-connected").textContent = String(state.sockets.connected) + " connected";
|
|
1104
|
+
fill(document.getElementById("sockets-defs"), table(
|
|
1105
|
+
["Path", "File", "Auth"],
|
|
1106
|
+
state.sockets.defs.map(function (socket) {
|
|
1107
|
+
return { cells: [socket.path, socket.file, socket.auth], socket: socket };
|
|
1108
|
+
}),
|
|
1109
|
+
function (row) {
|
|
1110
|
+
socketFilter = { path: row.socket.path, name: row.socket.name };
|
|
1111
|
+
setSocketTab("events");
|
|
1112
|
+
render(lastState);
|
|
1113
|
+
}
|
|
1114
|
+
));
|
|
1115
|
+
var socketEvents = state.sockets.events;
|
|
1116
|
+
if (socketFilter) {
|
|
1117
|
+
socketEvents = state.sockets.events.filter(function (event) {
|
|
1118
|
+
return socketEventMatches(event, socketFilter);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
renderFilter(
|
|
1122
|
+
document.getElementById("sockets-filter"),
|
|
1123
|
+
socketFilter ? "Filtered to " + socketFilter.path : null,
|
|
1124
|
+
function () {
|
|
1125
|
+
socketFilter = null;
|
|
1126
|
+
render(lastState);
|
|
1127
|
+
}
|
|
1128
|
+
);
|
|
1129
|
+
fill(document.getElementById("sockets-events"), table(
|
|
1130
|
+
["Time", "Kind", "Path / name", "User", "Detail", "Preview"],
|
|
1131
|
+
socketEvents.map(function (event) {
|
|
1132
|
+
var detail = event.error
|
|
1133
|
+
? { html: '<span class="bad-text">' + event.error + "</span>" }
|
|
1134
|
+
: [event.code, event.reason].filter(function (part) { return part !== undefined && part !== ""; }).join(" ");
|
|
1135
|
+
return {
|
|
1136
|
+
cells: [
|
|
1137
|
+
timeLabel(event.at),
|
|
1138
|
+
event.socketKind,
|
|
1139
|
+
event.path || event.name || "",
|
|
1140
|
+
event.userId || "",
|
|
1141
|
+
detail,
|
|
1142
|
+
event.preview || ""
|
|
1143
|
+
]
|
|
1144
|
+
};
|
|
1145
|
+
})
|
|
1146
|
+
));
|
|
1147
|
+
setSocketTab(socketTab);
|
|
787
1148
|
}
|
|
788
1149
|
document.querySelector('[data-tab="routes"]').addEventListener("click", function () {
|
|
789
1150
|
setApisTab("routes");
|
|
@@ -793,6 +1154,22 @@ export function inspectHtml(): string {
|
|
|
793
1154
|
setApisTab("calls");
|
|
794
1155
|
if (lastState) render(lastState);
|
|
795
1156
|
});
|
|
1157
|
+
document.querySelector('[data-storage-tab="objects"]').addEventListener("click", function () {
|
|
1158
|
+
setStorageTab("objects");
|
|
1159
|
+
});
|
|
1160
|
+
document.querySelector('[data-storage-tab="activity"]').addEventListener("click", function () {
|
|
1161
|
+
storageFilter = null;
|
|
1162
|
+
setStorageTab("activity");
|
|
1163
|
+
if (lastState) render(lastState);
|
|
1164
|
+
});
|
|
1165
|
+
document.querySelector('[data-socket-tab="sockets"]').addEventListener("click", function () {
|
|
1166
|
+
setSocketTab("sockets");
|
|
1167
|
+
});
|
|
1168
|
+
document.querySelector('[data-socket-tab="events"]').addEventListener("click", function () {
|
|
1169
|
+
socketFilter = null;
|
|
1170
|
+
setSocketTab("events");
|
|
1171
|
+
if (lastState) render(lastState);
|
|
1172
|
+
});
|
|
796
1173
|
async function poll() {
|
|
797
1174
|
try {
|
|
798
1175
|
var state = await fetch("/_robodev/state.json").then(function (r) { return r.json(); });
|
|
@@ -801,6 +1178,8 @@ export function inspectHtml(): string {
|
|
|
801
1178
|
}
|
|
802
1179
|
markNav();
|
|
803
1180
|
setApisTab("routes");
|
|
1181
|
+
setStorageTab("objects");
|
|
1182
|
+
setSocketTab("sockets");
|
|
804
1183
|
poll();
|
|
805
1184
|
setInterval(poll, 1000);
|
|
806
1185
|
</script>
|
package/src/dev-server.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
buildDashboardState,
|
|
37
37
|
isInspectPath,
|
|
38
38
|
shouldCountRequestHistogram,
|
|
39
|
+
truncateInspectPreview,
|
|
39
40
|
type DevInspectStore,
|
|
40
41
|
} from "./dev-inspect.js";
|
|
41
42
|
|
|
@@ -62,6 +63,7 @@ export type DevServerOptions = {
|
|
|
62
63
|
googleClientId?: string;
|
|
63
64
|
googleClientSecret?: string;
|
|
64
65
|
storageDir: string;
|
|
66
|
+
projectRoot?: string;
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
|
|
@@ -128,6 +130,7 @@ function routeClients(options: DevServerOptions, getRuntime: () => DevRuntime |
|
|
|
128
130
|
publicBaseUrl: options.listenOrigin,
|
|
129
131
|
projectId: options.projectId,
|
|
130
132
|
jwtSecret: options.jwtSecret,
|
|
133
|
+
projectRoot: options.projectRoot,
|
|
131
134
|
});
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -156,26 +159,52 @@ async function resolveSocketUser(
|
|
|
156
159
|
return { ok: true, user };
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
function recordSocketHandshake(
|
|
163
|
+
inspect: DevInspectStore,
|
|
164
|
+
input: {
|
|
165
|
+
path: string;
|
|
166
|
+
name?: string;
|
|
167
|
+
userId?: string | null;
|
|
168
|
+
error?: string;
|
|
169
|
+
},
|
|
170
|
+
): void {
|
|
171
|
+
inspect.record({
|
|
172
|
+
kind: "socket",
|
|
173
|
+
socketKind: "connect",
|
|
174
|
+
path: input.path,
|
|
175
|
+
name: input.name,
|
|
176
|
+
userId: input.userId,
|
|
177
|
+
error: input.error,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
159
181
|
async function handleLocalSocket(
|
|
160
182
|
socket: WebSocket,
|
|
161
183
|
request: FastifyRequest,
|
|
162
184
|
options: DevServerOptions,
|
|
163
185
|
getRuntime: () => DevRuntime | null,
|
|
164
186
|
): Promise<void> {
|
|
187
|
+
const pathname = pathnameOf(request.url);
|
|
165
188
|
const runtime = getRuntime();
|
|
166
189
|
if (!runtime) {
|
|
190
|
+
recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
|
|
167
191
|
socket.close(SOCKET_CLOSE_POLICY, "not_found");
|
|
168
192
|
return;
|
|
169
193
|
}
|
|
170
|
-
const pathname = pathnameOf(request.url);
|
|
171
194
|
const matched = matchSocketRoute(pathname, runtime.sockets);
|
|
172
195
|
if (!matched) {
|
|
196
|
+
recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
|
|
173
197
|
socket.close(SOCKET_CLOSE_POLICY, "not_found");
|
|
174
198
|
return;
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
const auth = await resolveSocketUser(options, matched.socket.def, request);
|
|
178
202
|
if (!auth.ok) {
|
|
203
|
+
recordSocketHandshake(options.inspect, {
|
|
204
|
+
path: pathname,
|
|
205
|
+
name: matched.socket.name,
|
|
206
|
+
error: "unauthorized",
|
|
207
|
+
});
|
|
179
208
|
socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
|
|
180
209
|
return;
|
|
181
210
|
}
|
|
@@ -215,12 +244,24 @@ async function handleLocalSocket(
|
|
|
215
244
|
onClose: matched.socket.def.onClose,
|
|
216
245
|
});
|
|
217
246
|
if (!session.ok) {
|
|
247
|
+
recordSocketHandshake(options.inspect, {
|
|
248
|
+
path: pathname,
|
|
249
|
+
name: matched.socket.name,
|
|
250
|
+
userId: auth.user?.id ?? null,
|
|
251
|
+
error: session.reason,
|
|
252
|
+
});
|
|
218
253
|
if (socket.readyState === socket.OPEN) {
|
|
219
254
|
socket.close(session.code, session.reason);
|
|
220
255
|
}
|
|
221
256
|
return;
|
|
222
257
|
}
|
|
223
258
|
|
|
259
|
+
recordSocketHandshake(options.inspect, {
|
|
260
|
+
path: pathname,
|
|
261
|
+
name: matched.socket.name,
|
|
262
|
+
userId: auth.user?.id ?? null,
|
|
263
|
+
});
|
|
264
|
+
|
|
224
265
|
let alive = true;
|
|
225
266
|
const pingTimer = setInterval(() => {
|
|
226
267
|
if (socket.readyState !== socket.OPEN) {
|
|
@@ -245,18 +286,52 @@ async function handleLocalSocket(
|
|
|
245
286
|
});
|
|
246
287
|
socket.on("message", (data, isBinary) => {
|
|
247
288
|
if (isBinary) {
|
|
289
|
+
options.inspect.record({
|
|
290
|
+
kind: "socket",
|
|
291
|
+
socketKind: "message",
|
|
292
|
+
path: pathname,
|
|
293
|
+
name: matched.socket.name,
|
|
294
|
+
userId: auth.user?.id ?? null,
|
|
295
|
+
error: "binary",
|
|
296
|
+
});
|
|
248
297
|
session.onBinary();
|
|
249
298
|
return;
|
|
250
299
|
}
|
|
251
300
|
const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
|
|
301
|
+
options.inspect.record({
|
|
302
|
+
kind: "socket",
|
|
303
|
+
socketKind: "message",
|
|
304
|
+
path: pathname,
|
|
305
|
+
name: matched.socket.name,
|
|
306
|
+
userId: auth.user?.id ?? null,
|
|
307
|
+
preview: truncateInspectPreview(text),
|
|
308
|
+
});
|
|
252
309
|
session.onText(text);
|
|
253
310
|
});
|
|
254
|
-
socket.on("close", () => {
|
|
311
|
+
socket.on("close", (code, reason) => {
|
|
255
312
|
clearInterval(pingTimer);
|
|
313
|
+
options.inspect.record({
|
|
314
|
+
kind: "socket",
|
|
315
|
+
socketKind: "close",
|
|
316
|
+
path: pathname,
|
|
317
|
+
name: matched.socket.name,
|
|
318
|
+
userId: auth.user?.id ?? null,
|
|
319
|
+
code,
|
|
320
|
+
reason: reason?.toString() || undefined,
|
|
321
|
+
});
|
|
256
322
|
session.onClose();
|
|
257
323
|
});
|
|
258
324
|
}
|
|
259
325
|
|
|
326
|
+
function recordStorageServe(
|
|
327
|
+
inspect: DevInspectStore,
|
|
328
|
+
key: string,
|
|
329
|
+
ok: boolean,
|
|
330
|
+
error?: string,
|
|
331
|
+
): void {
|
|
332
|
+
inspect.record({ kind: "storage", op: "serve", key, ok, error });
|
|
333
|
+
}
|
|
334
|
+
|
|
260
335
|
async function serveLocalStorage(
|
|
261
336
|
request: FastifyRequest,
|
|
262
337
|
reply: FastifyReply,
|
|
@@ -264,12 +339,13 @@ async function serveLocalStorage(
|
|
|
264
339
|
getRuntime: () => DevRuntime | null,
|
|
265
340
|
pathname: string,
|
|
266
341
|
): Promise<void> {
|
|
342
|
+
const encodedKey = pathname.slice("/storage/objects/".length);
|
|
267
343
|
const runtime = getRuntime();
|
|
268
344
|
if (!runtime) {
|
|
269
345
|
await reply.code(503).send({ error: "not_ready" });
|
|
346
|
+
recordStorageServe(options.inspect, encodedKey, false, "not_ready");
|
|
270
347
|
return;
|
|
271
348
|
}
|
|
272
|
-
const encodedKey = pathname.slice("/storage/objects/".length);
|
|
273
349
|
let key: string;
|
|
274
350
|
try {
|
|
275
351
|
key = decodeURIComponent(encodedKey);
|
|
@@ -278,9 +354,12 @@ async function serveLocalStorage(
|
|
|
278
354
|
const mapped = codedErrorBody(error);
|
|
279
355
|
if (mapped) {
|
|
280
356
|
await reply.code(mapped.status).send(mapped.body);
|
|
357
|
+
const mappedError = typeof mapped.body.error === "string" ? mapped.body.error : "invalid_key";
|
|
358
|
+
recordStorageServe(options.inspect, encodedKey, false, mappedError);
|
|
281
359
|
return;
|
|
282
360
|
}
|
|
283
361
|
await reply.code(400).send({ error: "invalid_key" });
|
|
362
|
+
recordStorageServe(options.inspect, encodedKey, false, "invalid_key");
|
|
284
363
|
return;
|
|
285
364
|
}
|
|
286
365
|
|
|
@@ -291,6 +370,7 @@ async function serveLocalStorage(
|
|
|
291
370
|
});
|
|
292
371
|
if (!resolved) {
|
|
293
372
|
await reply.code(404).send({ error: "not_found" });
|
|
373
|
+
recordStorageServe(options.inspect, key, false, "not_found");
|
|
294
374
|
return;
|
|
295
375
|
}
|
|
296
376
|
|
|
@@ -306,10 +386,12 @@ async function serveLocalStorage(
|
|
|
306
386
|
);
|
|
307
387
|
if (!resolved.row.public && !user && !signed) {
|
|
308
388
|
await reply.code(404).send({ error: "not_found" });
|
|
389
|
+
recordStorageServe(options.inspect, key, false, "not_found");
|
|
309
390
|
return;
|
|
310
391
|
}
|
|
311
392
|
|
|
312
393
|
await reply.type(resolved.row.content_type).send(resolved.body);
|
|
394
|
+
recordStorageServe(options.inspect, key, true);
|
|
313
395
|
}
|
|
314
396
|
|
|
315
397
|
/**
|
|
@@ -390,6 +472,7 @@ export async function startDevServer(
|
|
|
390
472
|
await buildDashboardState({
|
|
391
473
|
inspect: options.inspect,
|
|
392
474
|
runtime: getRuntime(),
|
|
475
|
+
projectId: options.projectId,
|
|
393
476
|
googleClientId: options.googleClientId,
|
|
394
477
|
googleClientSecret: options.googleClientSecret,
|
|
395
478
|
}),
|
package/src/dev.ts
CHANGED
|
@@ -191,6 +191,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
191
191
|
publicBaseUrl: `http://localhost:${parsed.port}`,
|
|
192
192
|
projectId: DEV_PROJECT_ID,
|
|
193
193
|
jwtSecret: DEV_JWT_SECRET,
|
|
194
|
+
projectRoot: project.root,
|
|
194
195
|
}),
|
|
195
196
|
onJobFinished: (event) => {
|
|
196
197
|
inspect.record({
|
|
@@ -310,6 +311,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
310
311
|
googleClientId: env.GOOGLE_CLIENT_ID,
|
|
311
312
|
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
312
313
|
storageDir,
|
|
314
|
+
projectRoot: project.root,
|
|
313
315
|
},
|
|
314
316
|
() => current,
|
|
315
317
|
);
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"rules:gen": "rulesync generate"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@robodev-ai/sdk": "^0.11.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.37.1",
|
|
16
16
|
"rulesync": "^8.18.0",
|
|
17
17
|
"typescript": "^5.9.2"
|
|
18
18
|
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"rules:gen": "rulesync generate"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@robodev-ai/sdk": "^0.11.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.37.1",
|
|
16
16
|
"rulesync": "^8.18.0",
|
|
17
17
|
"typescript": "^5.9.2"
|
|
18
18
|
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"rules:gen": "rulesync generate"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@robodev-ai/sdk": "^0.11.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.37.1",
|
|
16
16
|
"rulesync": "^8.18.0",
|
|
17
17
|
"typescript": "^5.9.2"
|
|
18
18
|
}
|