robodev 0.35.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 +2 -2
- package/src/dev-clients.ts +86 -10
- package/src/dev-inspect.test.ts +269 -7
- package/src/dev-inspect.ts +1064 -106
- package/src/dev-server.ts +117 -16
- package/src/dev.ts +11 -1
- 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/src/dev-inspect.ts
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isLocalAuthPath,
|
|
3
|
+
matchPathParams,
|
|
4
|
+
matchProjectRoute,
|
|
5
|
+
projectGoogleOverrideMode,
|
|
6
|
+
projectSocketCount,
|
|
7
|
+
socketAuthDisplay,
|
|
8
|
+
} from "@robodev-ai/runtime";
|
|
9
|
+
import type { ApiAuth } from "@robodev-ai/sdk";
|
|
10
|
+
|
|
1
11
|
export const DEV_INSPECT_MAX = 500;
|
|
12
|
+
export const HISTOGRAM_MINUTES = 120;
|
|
13
|
+
|
|
14
|
+
const DASHBOARD_HTML_PATHS = new Set([
|
|
15
|
+
"/_robodev",
|
|
16
|
+
"/_robodev/apis",
|
|
17
|
+
"/_robodev/jobs",
|
|
18
|
+
"/_robodev/emails",
|
|
19
|
+
"/_robodev/auth",
|
|
20
|
+
"/_robodev/storage",
|
|
21
|
+
"/_robodev/sockets",
|
|
22
|
+
"/_robodev/logs",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const DASHBOARD_JSON_PATHS = new Set(["/_robodev/state.json", "/_robodev/logs.json"]);
|
|
26
|
+
|
|
27
|
+
const HISTOGRAM_EXCLUDED_PATHS = new Set([
|
|
28
|
+
...DASHBOARD_HTML_PATHS,
|
|
29
|
+
...DASHBOARD_JSON_PATHS,
|
|
30
|
+
"/_robodev/docs",
|
|
31
|
+
"/_robodev/swagger",
|
|
32
|
+
"/docs",
|
|
33
|
+
"/swagger",
|
|
34
|
+
"/_robodev/scalar",
|
|
35
|
+
"/scalar",
|
|
36
|
+
"/openapi.json",
|
|
37
|
+
"/_robodev/ready",
|
|
38
|
+
]);
|
|
2
39
|
|
|
3
|
-
const
|
|
40
|
+
export const RESERVED_AUTH_ROUTES: Array<{ method: string; path: string; file: string }> = [
|
|
41
|
+
{ method: "POST", path: "/api/user/auth/register", file: "reserved" },
|
|
42
|
+
{ method: "POST", path: "/api/user/auth/login", file: "reserved" },
|
|
43
|
+
{ method: "POST", path: "/api/user/auth/refresh", file: "reserved" },
|
|
44
|
+
{ method: "GET", path: "/api/user/auth/magic-link", file: "reserved" },
|
|
45
|
+
{ method: "GET", path: "/api/user/auth/magic-link/callback", file: "reserved" },
|
|
46
|
+
{ method: "POST", path: "/api/user/auth/forgot-password", file: "reserved" },
|
|
47
|
+
{ method: "POST", path: "/api/user/auth/forgot-password/callback", file: "reserved" },
|
|
48
|
+
{ method: "GET", path: "/api/user/auth/google", file: "reserved" },
|
|
49
|
+
{ method: "GET", path: "/api/user/auth/google/callback", file: "reserved" },
|
|
50
|
+
{ method: "GET", path: "/api/user/auth/apple/callback", file: "reserved" },
|
|
51
|
+
{ method: "GET", path: "/api/user/me", file: "reserved" },
|
|
52
|
+
{ method: "PUT", path: "/api/user/me", file: "reserved" },
|
|
53
|
+
];
|
|
4
54
|
|
|
5
|
-
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";
|
|
6
62
|
|
|
7
63
|
type EventBase = {
|
|
8
64
|
id: string;
|
|
@@ -38,39 +94,251 @@ export type DevInspectJobEvent = EventBase & {
|
|
|
38
94
|
error: string;
|
|
39
95
|
};
|
|
40
96
|
|
|
97
|
+
export type DevInspectJobRunEvent = EventBase & {
|
|
98
|
+
kind: "job_run";
|
|
99
|
+
name: string;
|
|
100
|
+
status: "succeeded" | "failed";
|
|
101
|
+
payload?: unknown;
|
|
102
|
+
error?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
41
105
|
export type DevInspectLogEvent = EventBase & {
|
|
42
106
|
kind: "log";
|
|
43
107
|
message: string;
|
|
44
108
|
};
|
|
45
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
|
+
|
|
46
130
|
export type DevInspectEvent =
|
|
47
131
|
| DevInspectRequestEvent
|
|
48
132
|
| DevInspectEmailEvent
|
|
49
133
|
| DevInspectAuthCodeEvent
|
|
50
134
|
| DevInspectJobEvent
|
|
51
|
-
|
|
|
135
|
+
| DevInspectJobRunEvent
|
|
136
|
+
| DevInspectLogEvent
|
|
137
|
+
| DevInspectStorageEvent
|
|
138
|
+
| DevInspectSocketEvent;
|
|
52
139
|
|
|
53
140
|
export type DevInspectRecordInput =
|
|
54
141
|
| Omit<DevInspectRequestEvent, "id" | "at">
|
|
55
142
|
| Omit<DevInspectEmailEvent, "id" | "at">
|
|
56
143
|
| Omit<DevInspectAuthCodeEvent, "id" | "at">
|
|
57
144
|
| Omit<DevInspectJobEvent, "id" | "at">
|
|
58
|
-
| Omit<
|
|
145
|
+
| Omit<DevInspectJobRunEvent, "id" | "at">
|
|
146
|
+
| Omit<DevInspectLogEvent, "id" | "at">
|
|
147
|
+
| Omit<DevInspectStorageEvent, "id" | "at">
|
|
148
|
+
| Omit<DevInspectSocketEvent, "id" | "at">;
|
|
149
|
+
|
|
150
|
+
export type DashboardGoogle = "broker" | "local_override" | "incomplete";
|
|
151
|
+
|
|
152
|
+
export type RequestHistogramBucket = { t: number; c2xx: number; c4xx: number; c5xx: number };
|
|
153
|
+
export type JobHistogramBucket = { t: number; succeeded: number; failed: number };
|
|
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
|
+
|
|
191
|
+
export type DashboardState = {
|
|
192
|
+
overview: {
|
|
193
|
+
apiCount: number;
|
|
194
|
+
jobCount: number;
|
|
195
|
+
storageObjectCount: number;
|
|
196
|
+
socketCount: number;
|
|
197
|
+
google: DashboardGoogle;
|
|
198
|
+
requestHistogram: RequestHistogramBucket[];
|
|
199
|
+
jobHistogram: JobHistogramBucket[];
|
|
200
|
+
links: { swagger: string; scalar: string; openapi: string };
|
|
201
|
+
};
|
|
202
|
+
routes: Array<{ method: string; path: string; file: string }>;
|
|
203
|
+
calls: Array<{ at: number; method: string; path: string; status: number; durationMs: number }>;
|
|
204
|
+
jobs: Array<{ name: string; cron: string | null; file: string }>;
|
|
205
|
+
jobRuns: Array<{
|
|
206
|
+
at: number;
|
|
207
|
+
name: string;
|
|
208
|
+
status: "succeeded" | "failed";
|
|
209
|
+
payload?: unknown;
|
|
210
|
+
error?: string;
|
|
211
|
+
}>;
|
|
212
|
+
emails: Array<{ at: number; to: string; subject: string; text?: string }>;
|
|
213
|
+
auth: {
|
|
214
|
+
google: DashboardGoogle;
|
|
215
|
+
users: Array<{ id: string; email: string; name: string | null; createdAt: string }>;
|
|
216
|
+
};
|
|
217
|
+
storage: {
|
|
218
|
+
objects: DashboardStorageObject[];
|
|
219
|
+
activity: DashboardStorageActivity[];
|
|
220
|
+
};
|
|
221
|
+
sockets: {
|
|
222
|
+
connected: number;
|
|
223
|
+
defs: DashboardSocketDef[];
|
|
224
|
+
events: DashboardSocketEvent[];
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type DashboardRuntime = {
|
|
229
|
+
routes: Array<{ method: string; path: string; file: string }>;
|
|
230
|
+
jobs: Array<{ name: string; file: string; def: { cron?: string } }>;
|
|
231
|
+
sockets?: Array<{ name: string; path: string; file: string; def: { auth?: ApiAuth } }>;
|
|
232
|
+
pool?: {
|
|
233
|
+
query: <R extends Record<string, unknown> = Record<string, unknown>>(
|
|
234
|
+
sql: string,
|
|
235
|
+
values?: unknown[],
|
|
236
|
+
) => Promise<{ rows: R[] }>;
|
|
237
|
+
};
|
|
238
|
+
} | null;
|
|
59
239
|
|
|
60
240
|
export type DevInspectStore = {
|
|
61
241
|
record: (input: DevInspectRecordInput) => DevInspectEvent;
|
|
62
242
|
list: () => DevInspectEvent[];
|
|
243
|
+
incrementRequestHistogram: (status: number, at?: number) => void;
|
|
244
|
+
incrementJobHistogram: (outcome: "succeeded" | "failed", at?: number) => void;
|
|
245
|
+
requestHistogram: (now?: number) => RequestHistogramBucket[];
|
|
246
|
+
jobHistogram: (now?: number) => JobHistogramBucket[];
|
|
63
247
|
html: () => string;
|
|
64
248
|
};
|
|
65
249
|
|
|
66
|
-
/**
|
|
250
|
+
/** Dashboard HTML/JSON self-routes — do not print or record these as requests. */
|
|
67
251
|
export function isInspectPath(pathname: string): boolean {
|
|
68
|
-
return
|
|
252
|
+
return DASHBOARD_HTML_PATHS.has(pathname) || DASHBOARD_JSON_PATHS.has(pathname);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isHistogramExcludedPath(pathname: string): boolean {
|
|
256
|
+
return HISTOGRAM_EXCLUDED_PATHS.has(pathname);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Project file routes and `/api/user…` only. Unmatched 404s and reserved tools are out. */
|
|
260
|
+
export function shouldCountRequestHistogram(
|
|
261
|
+
pathname: string,
|
|
262
|
+
runtime: { routes: Array<{ method: string; path: string }> } | null,
|
|
263
|
+
method = "GET",
|
|
264
|
+
): boolean {
|
|
265
|
+
if (isHistogramExcludedPath(pathname)) return false;
|
|
266
|
+
if (isLocalAuthPath(pathname)) return true;
|
|
267
|
+
if (!runtime) return false;
|
|
268
|
+
return matchProjectRoute(pathname, method, runtime.routes) !== null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function callMatchesRoute(
|
|
272
|
+
call: { method: string; path: string },
|
|
273
|
+
route: { method: string; path: string },
|
|
274
|
+
): boolean {
|
|
275
|
+
if (call.method.toUpperCase() !== route.method.toUpperCase()) return false;
|
|
276
|
+
if (matchPathParams(route.path, call.path)) return true;
|
|
277
|
+
if (route.path.startsWith("/api/") || route.path === "/api") return false;
|
|
278
|
+
const prefixed = route.path === "/" ? "/api" : `/api${route.path}`;
|
|
279
|
+
return matchPathParams(prefixed, call.path) !== null;
|
|
280
|
+
}
|
|
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
|
+
|
|
317
|
+
export function googleDashboardStatus(mode: "both" | "one" | "none"): DashboardGoogle {
|
|
318
|
+
if (mode === "both") return "local_override";
|
|
319
|
+
if (mode === "one") return "incomplete";
|
|
320
|
+
return "broker";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function minuteOf(at: number): number {
|
|
324
|
+
return Math.floor(at / 60_000);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pruneMap<T>(map: Map<number, T>, nowMinute: number): void {
|
|
328
|
+
const oldest = nowMinute - HISTOGRAM_MINUTES + 1;
|
|
329
|
+
for (const key of map.keys()) {
|
|
330
|
+
if (key < oldest) map.delete(key);
|
|
331
|
+
}
|
|
69
332
|
}
|
|
70
333
|
|
|
71
334
|
export function createDevInspect(options?: { max?: number }): DevInspectStore {
|
|
72
335
|
const max = options?.max ?? DEV_INSPECT_MAX;
|
|
73
336
|
const events: DevInspectEvent[] = [];
|
|
337
|
+
const requestBuckets = new Map<
|
|
338
|
+
number,
|
|
339
|
+
{ minute: number; c2xx: number; c4xx: number; c5xx: number }
|
|
340
|
+
>();
|
|
341
|
+
const jobBuckets = new Map<number, { minute: number; succeeded: number; failed: number }>();
|
|
74
342
|
let nextId = 1;
|
|
75
343
|
|
|
76
344
|
function record(input: DevInspectRecordInput): DevInspectEvent {
|
|
@@ -86,142 +354,832 @@ export function createDevInspect(options?: { max?: number }): DevInspectStore {
|
|
|
86
354
|
return events.slice();
|
|
87
355
|
}
|
|
88
356
|
|
|
89
|
-
|
|
357
|
+
function incrementRequestHistogram(status: number, at = Date.now()): void {
|
|
358
|
+
if (status < 200 || (status >= 300 && status < 400) || status >= 600) return;
|
|
359
|
+
const minute = minuteOf(at);
|
|
360
|
+
pruneMap(requestBuckets, minute);
|
|
361
|
+
let bucket = requestBuckets.get(minute);
|
|
362
|
+
if (!bucket) {
|
|
363
|
+
bucket = { minute, c2xx: 0, c4xx: 0, c5xx: 0 };
|
|
364
|
+
requestBuckets.set(minute, bucket);
|
|
365
|
+
}
|
|
366
|
+
if (status < 300) bucket.c2xx += 1;
|
|
367
|
+
else if (status < 500) bucket.c4xx += 1;
|
|
368
|
+
else bucket.c5xx += 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function incrementJobHistogram(outcome: "succeeded" | "failed", at = Date.now()): void {
|
|
372
|
+
const minute = minuteOf(at);
|
|
373
|
+
pruneMap(jobBuckets, minute);
|
|
374
|
+
let bucket = jobBuckets.get(minute);
|
|
375
|
+
if (!bucket) {
|
|
376
|
+
bucket = { minute, succeeded: 0, failed: 0 };
|
|
377
|
+
jobBuckets.set(minute, bucket);
|
|
378
|
+
}
|
|
379
|
+
bucket[outcome] += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function requestHistogram(now = Date.now()): RequestHistogramBucket[] {
|
|
383
|
+
const nowMinute = minuteOf(now);
|
|
384
|
+
pruneMap(requestBuckets, nowMinute);
|
|
385
|
+
const out: RequestHistogramBucket[] = [];
|
|
386
|
+
for (let i = HISTOGRAM_MINUTES - 1; i >= 0; i--) {
|
|
387
|
+
const minute = nowMinute - i;
|
|
388
|
+
const bucket = requestBuckets.get(minute);
|
|
389
|
+
out.push({
|
|
390
|
+
t: minute * 60_000,
|
|
391
|
+
c2xx: bucket?.c2xx ?? 0,
|
|
392
|
+
c4xx: bucket?.c4xx ?? 0,
|
|
393
|
+
c5xx: bucket?.c5xx ?? 0,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function jobHistogram(now = Date.now()): JobHistogramBucket[] {
|
|
400
|
+
const nowMinute = minuteOf(now);
|
|
401
|
+
pruneMap(jobBuckets, nowMinute);
|
|
402
|
+
const out: JobHistogramBucket[] = [];
|
|
403
|
+
for (let i = HISTOGRAM_MINUTES - 1; i >= 0; i--) {
|
|
404
|
+
const minute = nowMinute - i;
|
|
405
|
+
const bucket = jobBuckets.get(minute);
|
|
406
|
+
out.push({
|
|
407
|
+
t: minute * 60_000,
|
|
408
|
+
succeeded: bucket?.succeeded ?? 0,
|
|
409
|
+
failed: bucket?.failed ?? 0,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
record,
|
|
417
|
+
list,
|
|
418
|
+
incrementRequestHistogram,
|
|
419
|
+
incrementJobHistogram,
|
|
420
|
+
requestHistogram,
|
|
421
|
+
jobHistogram,
|
|
422
|
+
html: inspectHtml,
|
|
423
|
+
};
|
|
90
424
|
}
|
|
91
425
|
|
|
92
|
-
|
|
426
|
+
function isoTimestamp(value: Date | string | undefined): string {
|
|
427
|
+
if (value instanceof Date) return value.toISOString();
|
|
428
|
+
return String(value ?? "");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function loadAuthUsers(
|
|
432
|
+
pool: NonNullable<DashboardRuntime>["pool"],
|
|
433
|
+
): Promise<DashboardState["auth"]["users"]> {
|
|
434
|
+
if (!pool) return [];
|
|
435
|
+
try {
|
|
436
|
+
const result = await pool.query<{
|
|
437
|
+
id: string;
|
|
438
|
+
email: string;
|
|
439
|
+
name: string | null;
|
|
440
|
+
created_at: Date | string;
|
|
441
|
+
}>(`SELECT id, email, name, created_at FROM robodev_auth.users ORDER BY created_at DESC`);
|
|
442
|
+
return result.rows.map((row) => ({
|
|
443
|
+
id: row.id,
|
|
444
|
+
email: row.email,
|
|
445
|
+
name: row.name ?? null,
|
|
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}`,
|
|
490
|
+
}));
|
|
491
|
+
} catch {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function buildDashboardState(input: {
|
|
497
|
+
inspect: DevInspectStore;
|
|
498
|
+
runtime: DashboardRuntime;
|
|
499
|
+
projectId?: string;
|
|
500
|
+
googleClientId?: string;
|
|
501
|
+
googleClientSecret?: string;
|
|
502
|
+
}): Promise<DashboardState> {
|
|
503
|
+
const google = googleDashboardStatus(
|
|
504
|
+
projectGoogleOverrideMode(input.googleClientId, input.googleClientSecret),
|
|
505
|
+
);
|
|
506
|
+
const fileRoutes = (input.runtime?.routes ?? []).map((route) => ({
|
|
507
|
+
method: route.method.toUpperCase(),
|
|
508
|
+
path: route.path,
|
|
509
|
+
file: route.file,
|
|
510
|
+
}));
|
|
511
|
+
const routes = [...fileRoutes, ...RESERVED_AUTH_ROUTES];
|
|
512
|
+
const newest = input.inspect.list().slice().reverse();
|
|
513
|
+
const calls = newest
|
|
514
|
+
.filter((event): event is DevInspectRequestEvent => event.kind === "request")
|
|
515
|
+
.filter((event) => !isHistogramExcludedPath(event.path))
|
|
516
|
+
.map((event) => ({
|
|
517
|
+
at: event.at,
|
|
518
|
+
method: event.method,
|
|
519
|
+
path: event.path,
|
|
520
|
+
status: event.status,
|
|
521
|
+
durationMs: event.durationMs,
|
|
522
|
+
}));
|
|
523
|
+
const jobRuns = newest
|
|
524
|
+
.filter((event): event is DevInspectJobRunEvent => event.kind === "job_run")
|
|
525
|
+
.map((event) => ({
|
|
526
|
+
at: event.at,
|
|
527
|
+
name: event.name,
|
|
528
|
+
status: event.status,
|
|
529
|
+
payload: event.payload,
|
|
530
|
+
error: event.error,
|
|
531
|
+
}));
|
|
532
|
+
const emails = newest
|
|
533
|
+
.filter((event): event is DevInspectEmailEvent => event.kind === "email")
|
|
534
|
+
.map((event) => ({
|
|
535
|
+
at: event.at,
|
|
536
|
+
to: event.to,
|
|
537
|
+
subject: event.subject,
|
|
538
|
+
text: event.text,
|
|
539
|
+
}));
|
|
540
|
+
const jobs = (input.runtime?.jobs ?? []).map((job) => ({
|
|
541
|
+
name: job.name,
|
|
542
|
+
cron: job.def.cron ?? null,
|
|
543
|
+
file: job.file,
|
|
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
|
+
}));
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
overview: {
|
|
576
|
+
apiCount: routes.length,
|
|
577
|
+
jobCount: jobs.length,
|
|
578
|
+
storageObjectCount: await loadStorageObjectCount(input.runtime?.pool),
|
|
579
|
+
socketCount: socketDefs.length,
|
|
580
|
+
google,
|
|
581
|
+
requestHistogram: input.inspect.requestHistogram(),
|
|
582
|
+
jobHistogram: input.inspect.jobHistogram(),
|
|
583
|
+
links: { swagger: "/_robodev/docs", scalar: "/_robodev/scalar", openapi: "/openapi.json" },
|
|
584
|
+
},
|
|
585
|
+
routes,
|
|
586
|
+
calls,
|
|
587
|
+
jobs,
|
|
588
|
+
jobRuns,
|
|
589
|
+
emails,
|
|
590
|
+
auth: {
|
|
591
|
+
google,
|
|
592
|
+
users: await loadAuthUsers(input.runtime?.pool),
|
|
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
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Inline dashboard — same pattern as runtime `swaggerHtml` (string template, no FE build). */
|
|
93
607
|
export function inspectHtml(): string {
|
|
94
608
|
return `<!doctype html>
|
|
95
609
|
<html>
|
|
96
610
|
<head>
|
|
97
611
|
<meta charset="utf-8" />
|
|
98
612
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
99
|
-
<title>
|
|
613
|
+
<title>robodev dev</title>
|
|
100
614
|
<style>
|
|
101
615
|
:root { color-scheme: light dark; }
|
|
102
616
|
body {
|
|
103
617
|
margin: 0 auto;
|
|
104
|
-
max-width:
|
|
105
|
-
padding:
|
|
618
|
+
max-width: 1080px;
|
|
619
|
+
padding: 20px 18px 48px;
|
|
106
620
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
107
621
|
line-height: 1.45;
|
|
108
622
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
.
|
|
128
|
-
.
|
|
623
|
+
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px 20px; margin-bottom: 20px; }
|
|
624
|
+
h1 { font-size: 1.15rem; font-weight: 650; margin: 0; }
|
|
625
|
+
nav { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.92rem; }
|
|
626
|
+
nav a { color: inherit; text-decoration: none; opacity: 0.7; }
|
|
627
|
+
nav a.current { opacity: 1; font-weight: 650; text-decoration: underline; text-underline-offset: 3px; }
|
|
628
|
+
.lead { color: #666; margin: 0 0 18px; font-size: 0.88rem; }
|
|
629
|
+
.cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
|
630
|
+
.card {
|
|
631
|
+
min-width: 140px;
|
|
632
|
+
flex: 1;
|
|
633
|
+
border: 1px solid #ddd;
|
|
634
|
+
border-radius: 8px;
|
|
635
|
+
padding: 10px 12px;
|
|
636
|
+
}
|
|
637
|
+
.card .label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; }
|
|
638
|
+
.card .value { font-size: 1.35rem; font-weight: 650; margin-top: 2px; }
|
|
639
|
+
.card .note { font-size: 0.82rem; margin-top: 4px; }
|
|
640
|
+
.links { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 20px; font-size: 0.9rem; }
|
|
641
|
+
.chart-wrap { margin: 0 0 18px; }
|
|
642
|
+
.chart-title { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; margin: 0 0 6px; }
|
|
643
|
+
.legend { font-size: 0.75rem; opacity: 0.75; margin: 0 0 8px; }
|
|
644
|
+
.legend span { margin-right: 10px; }
|
|
645
|
+
.swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
|
646
|
+
.chart { display: flex; align-items: flex-end; gap: 1px; height: 88px; border-bottom: 1px solid #ddd; }
|
|
647
|
+
.bar { flex: 1; display: flex; flex-direction: column-reverse; height: 100%; min-width: 0; }
|
|
648
|
+
.seg { width: 100%; }
|
|
649
|
+
.c2xx, .sw-2xx { background: #3b82f6; }
|
|
650
|
+
.c4xx, .sw-4xx { background: #f59e0b; }
|
|
651
|
+
.c5xx, .sw-5xx { background: #ef4444; }
|
|
652
|
+
.ok, .sw-ok { background: #22c55e; }
|
|
653
|
+
.bad, .sw-bad { background: #ef4444; }
|
|
654
|
+
.tabs { display: flex; gap: 8px; margin: 0 0 12px; }
|
|
655
|
+
.tabs button, .clear {
|
|
656
|
+
font: inherit;
|
|
657
|
+
font-size: 0.85rem;
|
|
658
|
+
padding: 4px 10px;
|
|
659
|
+
border: 1px solid #ccc;
|
|
660
|
+
border-radius: 6px;
|
|
661
|
+
background: transparent;
|
|
662
|
+
color: inherit;
|
|
663
|
+
cursor: pointer;
|
|
664
|
+
}
|
|
665
|
+
.tabs button.active { font-weight: 650; border-color: currentColor; }
|
|
666
|
+
table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
667
|
+
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
668
|
+
th { font-size: 0.72rem; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; font-family: inherit; }
|
|
669
|
+
tr.clickable { cursor: pointer; }
|
|
670
|
+
tr.clickable:hover td { background: color-mix(in srgb, currentColor 6%, transparent); }
|
|
671
|
+
.empty { color: #888; font-family: inherit; padding: 10px 0; }
|
|
672
|
+
.filter { font-size: 0.85rem; margin: 0 0 10px; display: flex; align-items: center; gap: 10px; }
|
|
673
|
+
.ok-text { color: #16a34a; }
|
|
674
|
+
.bad-text { color: #dc2626; }
|
|
675
|
+
.page { display: none; }
|
|
676
|
+
.page.visible { display: block; }
|
|
129
677
|
@media (prefers-color-scheme: dark) {
|
|
130
|
-
.lead, .empty, .
|
|
131
|
-
|
|
132
|
-
|
|
678
|
+
.lead, .empty, .card .label, .chart-title, .legend { color: #9aa0a6; }
|
|
679
|
+
.card, .chart { border-color: #333; }
|
|
680
|
+
th, td { border-bottom-color: #2a2a2a; }
|
|
681
|
+
.tabs button, .clear { border-color: #555; }
|
|
133
682
|
}
|
|
134
683
|
</style>
|
|
135
684
|
</head>
|
|
136
685
|
<body>
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
686
|
+
<header>
|
|
687
|
+
<h1>robodev dev</h1>
|
|
688
|
+
<nav>
|
|
689
|
+
<a href="/_robodev" data-nav="overview">Overview</a>
|
|
690
|
+
<a href="/_robodev/apis" data-nav="apis">APIs</a>
|
|
691
|
+
<a href="/_robodev/jobs" data-nav="jobs">Jobs</a>
|
|
692
|
+
<a href="/_robodev/emails" data-nav="emails">Emails</a>
|
|
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>
|
|
696
|
+
</nav>
|
|
697
|
+
</header>
|
|
698
|
+
<p class="lead">Local dashboard. Readonly. Polls every 1s.</p>
|
|
699
|
+
|
|
700
|
+
<section id="page-overview" class="page">
|
|
701
|
+
<div class="cards">
|
|
702
|
+
<div class="card"><div class="label">APIs</div><div class="value" id="api-count">0</div></div>
|
|
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>
|
|
706
|
+
<div class="card"><div class="label">Auth</div><div class="note" id="google-line"></div></div>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="links" id="doc-links"></div>
|
|
709
|
+
<div class="chart-wrap">
|
|
710
|
+
<p class="chart-title">Requests (2 hours)</p>
|
|
711
|
+
<p class="legend">
|
|
712
|
+
<span><i class="swatch sw-2xx"></i>2xx</span>
|
|
713
|
+
<span><i class="swatch sw-4xx"></i>4xx</span>
|
|
714
|
+
<span><i class="swatch sw-5xx"></i>5xx</span>
|
|
715
|
+
</p>
|
|
716
|
+
<div class="chart" id="req-chart"></div>
|
|
717
|
+
</div>
|
|
718
|
+
<div class="chart-wrap">
|
|
719
|
+
<p class="chart-title">Jobs (2 hours)</p>
|
|
720
|
+
<p class="legend">
|
|
721
|
+
<span><i class="swatch sw-ok"></i>succeeded</span>
|
|
722
|
+
<span><i class="swatch sw-bad"></i>failed</span>
|
|
723
|
+
</p>
|
|
724
|
+
<div class="chart" id="job-chart"></div>
|
|
725
|
+
</div>
|
|
726
|
+
</section>
|
|
727
|
+
|
|
728
|
+
<section id="page-apis" class="page">
|
|
729
|
+
<div class="tabs">
|
|
730
|
+
<button type="button" data-tab="routes">Routes</button>
|
|
731
|
+
<button type="button" data-tab="calls">Calls</button>
|
|
732
|
+
</div>
|
|
733
|
+
<div id="apis-filter" class="filter" hidden></div>
|
|
734
|
+
<div id="apis-routes"></div>
|
|
735
|
+
<div id="apis-calls" hidden></div>
|
|
736
|
+
</section>
|
|
737
|
+
|
|
738
|
+
<section id="page-jobs" class="page">
|
|
739
|
+
<p class="chart-title">Registered</p>
|
|
740
|
+
<div id="jobs-defs"></div>
|
|
741
|
+
<p class="chart-title" style="margin-top:18px">Runs</p>
|
|
742
|
+
<div id="jobs-runs"></div>
|
|
743
|
+
</section>
|
|
744
|
+
|
|
745
|
+
<section id="page-emails" class="page">
|
|
746
|
+
<div id="emails-list"></div>
|
|
747
|
+
</section>
|
|
748
|
+
|
|
749
|
+
<section id="page-auth" class="page">
|
|
750
|
+
<p class="lead" id="auth-google"></p>
|
|
751
|
+
<div id="auth-users"></div>
|
|
752
|
+
</section>
|
|
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
|
+
|
|
147
775
|
<script>
|
|
776
|
+
var PAGES = {
|
|
777
|
+
"/_robodev": "overview",
|
|
778
|
+
"/_robodev/apis": "apis",
|
|
779
|
+
"/_robodev/jobs": "jobs",
|
|
780
|
+
"/_robodev/emails": "emails",
|
|
781
|
+
"/_robodev/auth": "auth",
|
|
782
|
+
"/_robodev/storage": "storage",
|
|
783
|
+
"/_robodev/sockets": "sockets"
|
|
784
|
+
};
|
|
785
|
+
var apisTab = "routes";
|
|
786
|
+
var storageTab = "objects";
|
|
787
|
+
var socketTab = "sockets";
|
|
788
|
+
var callFilter = null;
|
|
789
|
+
var storageFilter = null;
|
|
790
|
+
var socketFilter = null;
|
|
791
|
+
var lastState = null;
|
|
792
|
+
|
|
793
|
+
function pagePath() {
|
|
794
|
+
return location.pathname.replace(/\\/$/, "") || "/";
|
|
795
|
+
}
|
|
796
|
+
function currentPage() {
|
|
797
|
+
return PAGES[pagePath()] || "overview";
|
|
798
|
+
}
|
|
799
|
+
function matchPathParams(pattern, pathname) {
|
|
800
|
+
var patternParts = pattern.split("/").filter(Boolean);
|
|
801
|
+
var pathParts = pathname.split("/").filter(Boolean);
|
|
802
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
803
|
+
var params = {};
|
|
804
|
+
for (var i = 0; i < patternParts.length; i++) {
|
|
805
|
+
var expected = patternParts[i];
|
|
806
|
+
var actual = pathParts[i];
|
|
807
|
+
if (expected.charAt(0) === ":") {
|
|
808
|
+
try { params[expected.slice(1)] = decodeURIComponent(actual); }
|
|
809
|
+
catch (e) { params[expected.slice(1)] = actual; }
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (expected !== actual) return null;
|
|
813
|
+
}
|
|
814
|
+
return params;
|
|
815
|
+
}
|
|
816
|
+
function callMatches(call, route) {
|
|
817
|
+
if (String(call.method).toUpperCase() !== String(route.method).toUpperCase()) return false;
|
|
818
|
+
if (matchPathParams(route.path, call.path)) return true;
|
|
819
|
+
if (route.path.indexOf("/api/") === 0 || route.path === "/api") return false;
|
|
820
|
+
var prefixed = route.path === "/" ? "/api" : "/api" + route.path;
|
|
821
|
+
return matchPathParams(prefixed, call.path) !== null;
|
|
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
|
+
}
|
|
148
834
|
function timeLabel(at) {
|
|
149
835
|
return new Date(at).toLocaleTimeString();
|
|
150
836
|
}
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
837
|
+
function googleLabel(mode) {
|
|
838
|
+
if (mode === "local_override") return "Local Google override";
|
|
839
|
+
if (mode === "incomplete") return "Google credentials incomplete (not override; Google is 503)";
|
|
840
|
+
return "Starbase identity broker";
|
|
841
|
+
}
|
|
842
|
+
function emptyNode() {
|
|
843
|
+
var p = document.createElement("p");
|
|
844
|
+
p.className = "empty";
|
|
845
|
+
p.textContent = "None yet";
|
|
846
|
+
return p;
|
|
847
|
+
}
|
|
848
|
+
function fill(el, node) {
|
|
849
|
+
el.replaceChildren();
|
|
850
|
+
el.appendChild(node);
|
|
851
|
+
}
|
|
852
|
+
function table(headers, rows, onRow) {
|
|
853
|
+
if (!rows.length) return emptyNode();
|
|
854
|
+
var tableEl = document.createElement("table");
|
|
855
|
+
var thead = document.createElement("thead");
|
|
856
|
+
var hr = document.createElement("tr");
|
|
857
|
+
for (var i = 0; i < headers.length; i++) {
|
|
858
|
+
var th = document.createElement("th");
|
|
859
|
+
th.textContent = headers[i];
|
|
860
|
+
hr.appendChild(th);
|
|
861
|
+
}
|
|
862
|
+
thead.appendChild(hr);
|
|
863
|
+
tableEl.appendChild(thead);
|
|
864
|
+
var tbody = document.createElement("tbody");
|
|
865
|
+
for (var r = 0; r < rows.length; r++) {
|
|
866
|
+
var tr = document.createElement("tr");
|
|
867
|
+
if (onRow) {
|
|
868
|
+
tr.className = "clickable";
|
|
869
|
+
(function (row) {
|
|
870
|
+
tr.addEventListener("click", function () { onRow(row); });
|
|
871
|
+
})(rows[r]);
|
|
872
|
+
}
|
|
873
|
+
var cells = rows[r].cells;
|
|
874
|
+
for (var c = 0; c < cells.length; c++) {
|
|
875
|
+
var td = document.createElement("td");
|
|
876
|
+
if (cells[c] && cells[c].html) td.innerHTML = cells[c].html;
|
|
877
|
+
else td.textContent = cells[c] == null ? "" : String(cells[c]);
|
|
878
|
+
tr.appendChild(td);
|
|
879
|
+
}
|
|
880
|
+
tbody.appendChild(tr);
|
|
172
881
|
}
|
|
173
|
-
|
|
882
|
+
tableEl.appendChild(tbody);
|
|
883
|
+
return tableEl;
|
|
174
884
|
}
|
|
175
885
|
function payloadText(payload) {
|
|
176
886
|
if (payload === undefined) return "";
|
|
177
|
-
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
887
|
+
try { return JSON.stringify(payload); }
|
|
888
|
+
catch (e) { return "[unserializable payload]"; }
|
|
889
|
+
}
|
|
890
|
+
function renderChart(el, buckets, series) {
|
|
891
|
+
el.replaceChildren();
|
|
892
|
+
var max = 1;
|
|
893
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
894
|
+
var total = 0;
|
|
895
|
+
for (var s = 0; s < series.length; s++) total += buckets[i][series[s].key] || 0;
|
|
896
|
+
if (total > max) max = total;
|
|
897
|
+
}
|
|
898
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
899
|
+
var col = document.createElement("div");
|
|
900
|
+
col.className = "bar";
|
|
901
|
+
for (var s = 0; s < series.length; s++) {
|
|
902
|
+
var n = buckets[i][series[s].key] || 0;
|
|
903
|
+
if (!n) continue;
|
|
904
|
+
var seg = document.createElement("div");
|
|
905
|
+
seg.className = "seg " + series[s].cls;
|
|
906
|
+
seg.style.height = ((n / max) * 100) + "%";
|
|
907
|
+
col.appendChild(seg);
|
|
908
|
+
}
|
|
909
|
+
el.appendChild(col);
|
|
181
910
|
}
|
|
182
911
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
912
|
+
function markNav() {
|
|
913
|
+
var page = currentPage();
|
|
914
|
+
var links = document.querySelectorAll("nav a");
|
|
915
|
+
for (var i = 0; i < links.length; i++) {
|
|
916
|
+
links[i].className = links[i].getAttribute("data-nav") === page ? "current" : "";
|
|
917
|
+
}
|
|
918
|
+
var sections = document.querySelectorAll(".page");
|
|
919
|
+
for (var i = 0; i < sections.length; i++) {
|
|
920
|
+
sections[i].className = sections[i].id === "page-" + page ? "page visible" : "page";
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function setApisTab(tab) {
|
|
924
|
+
apisTab = tab;
|
|
925
|
+
document.querySelector('[data-tab="routes"]').className = tab === "routes" ? "active" : "";
|
|
926
|
+
document.querySelector('[data-tab="calls"]').className = tab === "calls" ? "active" : "";
|
|
927
|
+
document.getElementById("apis-routes").hidden = tab !== "routes";
|
|
928
|
+
document.getElementById("apis-calls").hidden = tab !== "calls";
|
|
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
|
+
}
|
|
962
|
+
function render(state) {
|
|
963
|
+
lastState = state;
|
|
964
|
+
markNav();
|
|
965
|
+
document.getElementById("api-count").textContent = String(state.overview.apiCount);
|
|
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);
|
|
969
|
+
document.getElementById("google-line").textContent = googleLabel(state.overview.google);
|
|
970
|
+
var links = document.getElementById("doc-links");
|
|
971
|
+
links.replaceChildren();
|
|
972
|
+
var items = [
|
|
973
|
+
{ href: state.overview.links.swagger, label: "Swagger" },
|
|
974
|
+
{ href: state.overview.links.scalar, label: "Scalar" },
|
|
975
|
+
{ href: state.overview.links.openapi, label: "openapi.json" }
|
|
976
|
+
];
|
|
977
|
+
for (var i = 0; i < items.length; i++) {
|
|
978
|
+
var a = document.createElement("a");
|
|
979
|
+
a.href = items[i].href;
|
|
980
|
+
a.textContent = items[i].label;
|
|
981
|
+
links.appendChild(a);
|
|
982
|
+
}
|
|
983
|
+
renderChart(document.getElementById("req-chart"), state.overview.requestHistogram, [
|
|
984
|
+
{ key: "c2xx", cls: "c2xx" },
|
|
985
|
+
{ key: "c4xx", cls: "c4xx" },
|
|
986
|
+
{ key: "c5xx", cls: "c5xx" }
|
|
987
|
+
]);
|
|
988
|
+
renderChart(document.getElementById("job-chart"), state.overview.jobHistogram, [
|
|
989
|
+
{ key: "succeeded", cls: "ok" },
|
|
990
|
+
{ key: "failed", cls: "bad" }
|
|
991
|
+
]);
|
|
992
|
+
|
|
993
|
+
fill(document.getElementById("apis-routes"), table(
|
|
994
|
+
["Method", "Path", "File"],
|
|
995
|
+
state.routes.map(function (route) {
|
|
996
|
+
return { cells: [route.method, route.path, route.file], route: route };
|
|
997
|
+
}),
|
|
998
|
+
function (row) {
|
|
999
|
+
callFilter = { method: row.route.method, path: row.route.path };
|
|
1000
|
+
setApisTab("calls");
|
|
1001
|
+
render(lastState);
|
|
1002
|
+
}
|
|
1003
|
+
));
|
|
1004
|
+
var filtered = state.calls;
|
|
1005
|
+
if (callFilter) {
|
|
1006
|
+
filtered = state.calls.filter(function (call) { return callMatches(call, callFilter); });
|
|
1007
|
+
}
|
|
1008
|
+
var filterEl = document.getElementById("apis-filter");
|
|
1009
|
+
if (callFilter) {
|
|
1010
|
+
filterEl.hidden = false;
|
|
1011
|
+
filterEl.replaceChildren();
|
|
1012
|
+
var label = document.createElement("span");
|
|
1013
|
+
label.textContent = "Filtered to " + callFilter.method + " " + callFilter.path;
|
|
1014
|
+
var clear = document.createElement("button");
|
|
1015
|
+
clear.type = "button";
|
|
1016
|
+
clear.className = "clear";
|
|
1017
|
+
clear.textContent = "Clear filter";
|
|
1018
|
+
clear.addEventListener("click", function () {
|
|
1019
|
+
callFilter = null;
|
|
1020
|
+
render(lastState);
|
|
1021
|
+
});
|
|
1022
|
+
filterEl.appendChild(label);
|
|
1023
|
+
filterEl.appendChild(clear);
|
|
1024
|
+
} else {
|
|
1025
|
+
filterEl.hidden = true;
|
|
1026
|
+
filterEl.replaceChildren();
|
|
1027
|
+
}
|
|
1028
|
+
fill(document.getElementById("apis-calls"), table(
|
|
1029
|
+
["Time", "Method", "Path", "Status", "Duration"],
|
|
1030
|
+
filtered.map(function (call) {
|
|
1031
|
+
return { cells: [timeLabel(call.at), call.method, call.path, call.status, call.durationMs + "ms"] };
|
|
1032
|
+
})
|
|
1033
|
+
));
|
|
1034
|
+
setApisTab(apisTab);
|
|
1035
|
+
|
|
1036
|
+
fill(document.getElementById("jobs-defs"), table(
|
|
1037
|
+
["Name", "Cron", "File"],
|
|
1038
|
+
state.jobs.map(function (job) {
|
|
1039
|
+
return { cells: [job.name, job.cron || "", job.file] };
|
|
1040
|
+
})
|
|
1041
|
+
));
|
|
1042
|
+
fill(document.getElementById("jobs-runs"), table(
|
|
1043
|
+
["Time", "Name", "Status", "Payload", "Error"],
|
|
1044
|
+
state.jobRuns.map(function (run) {
|
|
1045
|
+
var status = run.status === "succeeded"
|
|
1046
|
+
? { html: '<span class="ok-text">succeeded</span>' }
|
|
1047
|
+
: { html: '<span class="bad-text">failed</span>' };
|
|
1048
|
+
return { cells: [timeLabel(run.at), run.name, status, payloadText(run.payload), run.error || ""] };
|
|
1049
|
+
})
|
|
1050
|
+
));
|
|
1051
|
+
|
|
1052
|
+
fill(document.getElementById("emails-list"), table(
|
|
1053
|
+
["Time", "To", "Subject", "Body"],
|
|
1054
|
+
state.emails.map(function (email) {
|
|
1055
|
+
return { cells: [timeLabel(email.at), email.to, email.subject, email.text || ""] };
|
|
1056
|
+
})
|
|
1057
|
+
));
|
|
1058
|
+
|
|
1059
|
+
document.getElementById("auth-google").textContent = googleLabel(state.auth.google);
|
|
1060
|
+
fill(document.getElementById("auth-users"), table(
|
|
1061
|
+
["Id", "Email", "Name", "Created"],
|
|
1062
|
+
state.auth.users.map(function (user) {
|
|
1063
|
+
return { cells: [user.id, user.email, user.name || "", user.createdAt] };
|
|
1064
|
+
})
|
|
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
|
+
}
|
|
211
1091
|
);
|
|
212
|
-
fill(
|
|
213
|
-
"
|
|
214
|
-
|
|
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
|
+
}
|
|
215
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);
|
|
216
1148
|
}
|
|
1149
|
+
document.querySelector('[data-tab="routes"]').addEventListener("click", function () {
|
|
1150
|
+
setApisTab("routes");
|
|
1151
|
+
});
|
|
1152
|
+
document.querySelector('[data-tab="calls"]').addEventListener("click", function () {
|
|
1153
|
+
callFilter = null;
|
|
1154
|
+
setApisTab("calls");
|
|
1155
|
+
if (lastState) render(lastState);
|
|
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
|
+
});
|
|
217
1173
|
async function poll() {
|
|
218
1174
|
try {
|
|
219
|
-
|
|
220
|
-
render(
|
|
221
|
-
} catch {
|
|
222
|
-
/* keep last render */
|
|
223
|
-
}
|
|
1175
|
+
var state = await fetch("/_robodev/state.json").then(function (r) { return r.json(); });
|
|
1176
|
+
render(state);
|
|
1177
|
+
} catch (e) { /* keep last render */ }
|
|
224
1178
|
}
|
|
1179
|
+
markNav();
|
|
1180
|
+
setApisTab("routes");
|
|
1181
|
+
setStorageTab("objects");
|
|
1182
|
+
setSocketTab("sockets");
|
|
225
1183
|
poll();
|
|
226
1184
|
setInterval(poll, 1000);
|
|
227
1185
|
</script>
|