robodev 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/dev-clients.ts +85 -10
- package/src/dev-inspect.test.ts +133 -0
- package/src/dev-inspect.ts +387 -8
- package/src/dev-server.ts +84 -3
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
package/src/dev-clients.ts
CHANGED
|
@@ -4,8 +4,18 @@ import {
|
|
|
4
4
|
type StorageQueryable,
|
|
5
5
|
} from "@robodev-ai/runtime";
|
|
6
6
|
import type { RouteClients } from "@robodev-ai/runtime";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
7
|
+
import type {
|
|
8
|
+
EmailClient,
|
|
9
|
+
JobEnqueueInput,
|
|
10
|
+
LlmClient,
|
|
11
|
+
SocketsClient,
|
|
12
|
+
StorageClient,
|
|
13
|
+
} from "@robodev-ai/sdk";
|
|
14
|
+
import {
|
|
15
|
+
truncateInspectPreview,
|
|
16
|
+
type DevInspectStore,
|
|
17
|
+
type StorageInspectOp,
|
|
18
|
+
} from "./dev-inspect.js";
|
|
9
19
|
|
|
10
20
|
/**
|
|
11
21
|
* The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
|
|
@@ -21,6 +31,68 @@ function errorMessage(error: unknown): string {
|
|
|
21
31
|
return error instanceof Error ? error.message : String(error);
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
function inspectStorageClient(client: StorageClient, inspect: DevInspectStore): StorageClient {
|
|
35
|
+
async function tracked<T>(op: StorageInspectOp, key: string, run: () => Promise<T>): Promise<T> {
|
|
36
|
+
try {
|
|
37
|
+
const result = await run();
|
|
38
|
+
inspect.record({ kind: "storage", op, key, ok: true });
|
|
39
|
+
return result;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
inspect.record({
|
|
42
|
+
kind: "storage",
|
|
43
|
+
op,
|
|
44
|
+
key,
|
|
45
|
+
ok: false,
|
|
46
|
+
error: errorMessage(error),
|
|
47
|
+
});
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
upload: (key, data, options) => tracked("upload", key, () => client.upload(key, data, options)),
|
|
53
|
+
get: (key) => tracked("get", key, () => client.get(key)),
|
|
54
|
+
getUrl: (key, options) => tracked("getUrl", key, () => client.getUrl(key, options)),
|
|
55
|
+
delete: (key) => tracked("delete", key, () => client.delete(key)),
|
|
56
|
+
list: (options) => tracked("list", options?.prefix ?? "", () => client.list(options)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inspectSocketsClient(client: SocketsClient, inspect: DevInspectStore): SocketsClient {
|
|
61
|
+
function tracked(
|
|
62
|
+
socketKind: "send" | "broadcast",
|
|
63
|
+
name: string,
|
|
64
|
+
payload: unknown,
|
|
65
|
+
run: () => void,
|
|
66
|
+
) {
|
|
67
|
+
try {
|
|
68
|
+
run();
|
|
69
|
+
inspect.record({
|
|
70
|
+
kind: "socket",
|
|
71
|
+
socketKind,
|
|
72
|
+
name,
|
|
73
|
+
preview: truncateInspectPreview(payload),
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
inspect.record({
|
|
77
|
+
kind: "socket",
|
|
78
|
+
socketKind,
|
|
79
|
+
name,
|
|
80
|
+
error: errorMessage(error),
|
|
81
|
+
preview: truncateInspectPreview(payload),
|
|
82
|
+
});
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
send(input) {
|
|
88
|
+
tracked("send", input.name, input.payload, () => client.send(input));
|
|
89
|
+
},
|
|
90
|
+
broadcast(input) {
|
|
91
|
+
tracked("broadcast", input.name, input.payload, () => client.broadcast(input));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
24
96
|
/** Prints mail to stdout so local signup and reset flows are followable. */
|
|
25
97
|
export function consoleEmailClient(
|
|
26
98
|
log: (line: string) => void,
|
|
@@ -65,13 +137,16 @@ export function devRouteClients(input: {
|
|
|
65
137
|
subscribe: () => notConfigured("llm_not_configured"),
|
|
66
138
|
destroy: () => notConfigured("llm_not_configured"),
|
|
67
139
|
},
|
|
68
|
-
storage:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
140
|
+
storage: inspectStorageClient(
|
|
141
|
+
createLocalStorageClient({
|
|
142
|
+
query: input.query,
|
|
143
|
+
objectsDir: input.objectsDir,
|
|
144
|
+
publicBaseUrl: input.publicBaseUrl,
|
|
145
|
+
projectId: input.projectId,
|
|
146
|
+
jwtSecret: input.jwtSecret,
|
|
147
|
+
}),
|
|
148
|
+
input.inspect,
|
|
149
|
+
),
|
|
75
150
|
push: { send: () => notConfigured("push_not_configured") },
|
|
76
151
|
jobs: {
|
|
77
152
|
enqueue: async (job) => {
|
|
@@ -96,7 +171,7 @@ export function devRouteClients(input: {
|
|
|
96
171
|
}
|
|
97
172
|
},
|
|
98
173
|
},
|
|
99
|
-
sockets: createProjectSocketsClient(input.projectId),
|
|
174
|
+
sockets: inspectSocketsClient(createProjectSocketsClient(input.projectId), input.inspect),
|
|
100
175
|
env: input.env,
|
|
101
176
|
};
|
|
102
177
|
}
|
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
|
|
|
@@ -156,26 +157,52 @@ async function resolveSocketUser(
|
|
|
156
157
|
return { ok: true, user };
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
function recordSocketHandshake(
|
|
161
|
+
inspect: DevInspectStore,
|
|
162
|
+
input: {
|
|
163
|
+
path: string;
|
|
164
|
+
name?: string;
|
|
165
|
+
userId?: string | null;
|
|
166
|
+
error?: string;
|
|
167
|
+
},
|
|
168
|
+
): void {
|
|
169
|
+
inspect.record({
|
|
170
|
+
kind: "socket",
|
|
171
|
+
socketKind: "connect",
|
|
172
|
+
path: input.path,
|
|
173
|
+
name: input.name,
|
|
174
|
+
userId: input.userId,
|
|
175
|
+
error: input.error,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
async function handleLocalSocket(
|
|
160
180
|
socket: WebSocket,
|
|
161
181
|
request: FastifyRequest,
|
|
162
182
|
options: DevServerOptions,
|
|
163
183
|
getRuntime: () => DevRuntime | null,
|
|
164
184
|
): Promise<void> {
|
|
185
|
+
const pathname = pathnameOf(request.url);
|
|
165
186
|
const runtime = getRuntime();
|
|
166
187
|
if (!runtime) {
|
|
188
|
+
recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
|
|
167
189
|
socket.close(SOCKET_CLOSE_POLICY, "not_found");
|
|
168
190
|
return;
|
|
169
191
|
}
|
|
170
|
-
const pathname = pathnameOf(request.url);
|
|
171
192
|
const matched = matchSocketRoute(pathname, runtime.sockets);
|
|
172
193
|
if (!matched) {
|
|
194
|
+
recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
|
|
173
195
|
socket.close(SOCKET_CLOSE_POLICY, "not_found");
|
|
174
196
|
return;
|
|
175
197
|
}
|
|
176
198
|
|
|
177
199
|
const auth = await resolveSocketUser(options, matched.socket.def, request);
|
|
178
200
|
if (!auth.ok) {
|
|
201
|
+
recordSocketHandshake(options.inspect, {
|
|
202
|
+
path: pathname,
|
|
203
|
+
name: matched.socket.name,
|
|
204
|
+
error: "unauthorized",
|
|
205
|
+
});
|
|
179
206
|
socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
|
|
180
207
|
return;
|
|
181
208
|
}
|
|
@@ -215,12 +242,24 @@ async function handleLocalSocket(
|
|
|
215
242
|
onClose: matched.socket.def.onClose,
|
|
216
243
|
});
|
|
217
244
|
if (!session.ok) {
|
|
245
|
+
recordSocketHandshake(options.inspect, {
|
|
246
|
+
path: pathname,
|
|
247
|
+
name: matched.socket.name,
|
|
248
|
+
userId: auth.user?.id ?? null,
|
|
249
|
+
error: session.reason,
|
|
250
|
+
});
|
|
218
251
|
if (socket.readyState === socket.OPEN) {
|
|
219
252
|
socket.close(session.code, session.reason);
|
|
220
253
|
}
|
|
221
254
|
return;
|
|
222
255
|
}
|
|
223
256
|
|
|
257
|
+
recordSocketHandshake(options.inspect, {
|
|
258
|
+
path: pathname,
|
|
259
|
+
name: matched.socket.name,
|
|
260
|
+
userId: auth.user?.id ?? null,
|
|
261
|
+
});
|
|
262
|
+
|
|
224
263
|
let alive = true;
|
|
225
264
|
const pingTimer = setInterval(() => {
|
|
226
265
|
if (socket.readyState !== socket.OPEN) {
|
|
@@ -245,18 +284,52 @@ async function handleLocalSocket(
|
|
|
245
284
|
});
|
|
246
285
|
socket.on("message", (data, isBinary) => {
|
|
247
286
|
if (isBinary) {
|
|
287
|
+
options.inspect.record({
|
|
288
|
+
kind: "socket",
|
|
289
|
+
socketKind: "message",
|
|
290
|
+
path: pathname,
|
|
291
|
+
name: matched.socket.name,
|
|
292
|
+
userId: auth.user?.id ?? null,
|
|
293
|
+
error: "binary",
|
|
294
|
+
});
|
|
248
295
|
session.onBinary();
|
|
249
296
|
return;
|
|
250
297
|
}
|
|
251
298
|
const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
|
|
299
|
+
options.inspect.record({
|
|
300
|
+
kind: "socket",
|
|
301
|
+
socketKind: "message",
|
|
302
|
+
path: pathname,
|
|
303
|
+
name: matched.socket.name,
|
|
304
|
+
userId: auth.user?.id ?? null,
|
|
305
|
+
preview: truncateInspectPreview(text),
|
|
306
|
+
});
|
|
252
307
|
session.onText(text);
|
|
253
308
|
});
|
|
254
|
-
socket.on("close", () => {
|
|
309
|
+
socket.on("close", (code, reason) => {
|
|
255
310
|
clearInterval(pingTimer);
|
|
311
|
+
options.inspect.record({
|
|
312
|
+
kind: "socket",
|
|
313
|
+
socketKind: "close",
|
|
314
|
+
path: pathname,
|
|
315
|
+
name: matched.socket.name,
|
|
316
|
+
userId: auth.user?.id ?? null,
|
|
317
|
+
code,
|
|
318
|
+
reason: reason?.toString() || undefined,
|
|
319
|
+
});
|
|
256
320
|
session.onClose();
|
|
257
321
|
});
|
|
258
322
|
}
|
|
259
323
|
|
|
324
|
+
function recordStorageServe(
|
|
325
|
+
inspect: DevInspectStore,
|
|
326
|
+
key: string,
|
|
327
|
+
ok: boolean,
|
|
328
|
+
error?: string,
|
|
329
|
+
): void {
|
|
330
|
+
inspect.record({ kind: "storage", op: "serve", key, ok, error });
|
|
331
|
+
}
|
|
332
|
+
|
|
260
333
|
async function serveLocalStorage(
|
|
261
334
|
request: FastifyRequest,
|
|
262
335
|
reply: FastifyReply,
|
|
@@ -264,12 +337,13 @@ async function serveLocalStorage(
|
|
|
264
337
|
getRuntime: () => DevRuntime | null,
|
|
265
338
|
pathname: string,
|
|
266
339
|
): Promise<void> {
|
|
340
|
+
const encodedKey = pathname.slice("/storage/objects/".length);
|
|
267
341
|
const runtime = getRuntime();
|
|
268
342
|
if (!runtime) {
|
|
269
343
|
await reply.code(503).send({ error: "not_ready" });
|
|
344
|
+
recordStorageServe(options.inspect, encodedKey, false, "not_ready");
|
|
270
345
|
return;
|
|
271
346
|
}
|
|
272
|
-
const encodedKey = pathname.slice("/storage/objects/".length);
|
|
273
347
|
let key: string;
|
|
274
348
|
try {
|
|
275
349
|
key = decodeURIComponent(encodedKey);
|
|
@@ -278,9 +352,12 @@ async function serveLocalStorage(
|
|
|
278
352
|
const mapped = codedErrorBody(error);
|
|
279
353
|
if (mapped) {
|
|
280
354
|
await reply.code(mapped.status).send(mapped.body);
|
|
355
|
+
const mappedError = typeof mapped.body.error === "string" ? mapped.body.error : "invalid_key";
|
|
356
|
+
recordStorageServe(options.inspect, encodedKey, false, mappedError);
|
|
281
357
|
return;
|
|
282
358
|
}
|
|
283
359
|
await reply.code(400).send({ error: "invalid_key" });
|
|
360
|
+
recordStorageServe(options.inspect, encodedKey, false, "invalid_key");
|
|
284
361
|
return;
|
|
285
362
|
}
|
|
286
363
|
|
|
@@ -291,6 +368,7 @@ async function serveLocalStorage(
|
|
|
291
368
|
});
|
|
292
369
|
if (!resolved) {
|
|
293
370
|
await reply.code(404).send({ error: "not_found" });
|
|
371
|
+
recordStorageServe(options.inspect, key, false, "not_found");
|
|
294
372
|
return;
|
|
295
373
|
}
|
|
296
374
|
|
|
@@ -306,10 +384,12 @@ async function serveLocalStorage(
|
|
|
306
384
|
);
|
|
307
385
|
if (!resolved.row.public && !user && !signed) {
|
|
308
386
|
await reply.code(404).send({ error: "not_found" });
|
|
387
|
+
recordStorageServe(options.inspect, key, false, "not_found");
|
|
309
388
|
return;
|
|
310
389
|
}
|
|
311
390
|
|
|
312
391
|
await reply.type(resolved.row.content_type).send(resolved.body);
|
|
392
|
+
recordStorageServe(options.inspect, key, true);
|
|
313
393
|
}
|
|
314
394
|
|
|
315
395
|
/**
|
|
@@ -390,6 +470,7 @@ export async function startDevServer(
|
|
|
390
470
|
await buildDashboardState({
|
|
391
471
|
inspect: options.inspect,
|
|
392
472
|
runtime: getRuntime(),
|
|
473
|
+
projectId: options.projectId,
|
|
393
474
|
googleClientId: options.googleClientId,
|
|
394
475
|
googleClientSecret: options.googleClientSecret,
|
|
395
476
|
}),
|