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.
@@ -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 INSPECT_PATHS = new Set(["/_robodev", "/_robodev/logs", "/_robodev/logs.json"]);
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 type DevInspectKind = "request" | "email" | "auth_code" | "job" | "log";
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
- | DevInspectLogEvent;
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<DevInspectLogEvent, "id" | "at">;
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
- /** Inspect HTML/JSON self-routes — do not print or record these as requests. */
250
+ /** Dashboard HTML/JSON self-routes — do not print or record these as requests. */
67
251
  export function isInspectPath(pathname: string): boolean {
68
- return INSPECT_PATHS.has(pathname);
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
- return { record, list, html: inspectHtml };
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
- /** Inline inspect page — same pattern as runtime `swaggerHtml` (string template, no FE build). */
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>Robodev Inspect</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: 960px;
105
- padding: 24px 20px 48px;
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
- h1 { font-size: 1.25rem; font-weight: 650; margin: 0 0 4px; }
110
- .lead { color: #666; margin: 0 0 24px; font-size: 0.9rem; }
111
- h2 {
112
- font-size: 0.75rem;
113
- font-weight: 700;
114
- letter-spacing: 0.06em;
115
- text-transform: uppercase;
116
- margin: 28px 0 8px;
117
- }
118
- ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid #ddd; }
119
- li {
120
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
121
- font-size: 12.5px;
122
- padding: 8px 0;
123
- border-bottom: 1px solid #eee;
124
- white-space: pre-wrap;
125
- word-break: break-word;
126
- }
127
- .empty { color: #888; font-family: inherit; }
128
- .meta { color: #888; }
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, .meta { color: #9aa0a6; }
131
- ul { border-top-color: #333; }
132
- li { border-bottom-color: #2a2a2a; }
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
- <h1>Inspect</h1>
138
- <p class="lead">Local <code>robodev dev</code> events. Newest first. Polls every 1s.</p>
139
- <h2>Requests</h2>
140
- <ul id="requests"></ul>
141
- <h2>Emails</h2>
142
- <ul id="emails"></ul>
143
- <h2>Jobs</h2>
144
- <ul id="jobs"></ul>
145
- <h2>Logs</h2>
146
- <ul id="logs"></ul>
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 emptyRow() {
152
- const li = document.createElement("li");
153
- li.className = "empty";
154
- li.textContent = "None yet";
155
- return li;
156
- }
157
- function line(at, text) {
158
- const li = document.createElement("li");
159
- const meta = document.createElement("span");
160
- meta.className = "meta";
161
- meta.textContent = timeLabel(at) + " ";
162
- li.appendChild(meta);
163
- li.appendChild(document.createTextNode(text));
164
- return li;
165
- }
166
- function fill(id, rows) {
167
- const ul = document.getElementById(id);
168
- ul.replaceChildren();
169
- if (rows.length === 0) {
170
- ul.appendChild(emptyRow());
171
- return;
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
- for (const row of rows) ul.appendChild(row);
882
+ tableEl.appendChild(tbody);
883
+ return tableEl;
174
884
  }
175
885
  function payloadText(payload) {
176
886
  if (payload === undefined) return "";
177
- try {
178
- return " " + JSON.stringify(payload);
179
- } catch {
180
- return " [unserializable payload]";
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 render(events) {
184
- const newest = events.slice().reverse();
185
- fill(
186
- "requests",
187
- newest
188
- .filter((e) => e.kind === "request")
189
- .map((e) => line(e.at, e.method + " " + e.path + " " + e.status + " " + e.durationMs + "ms")),
190
- );
191
- fill(
192
- "emails",
193
- newest
194
- .filter((e) => e.kind === "email" || e.kind === "auth_code")
195
- .map((e) => {
196
- if (e.kind === "auth_code") {
197
- return line(e.at, e.type + " " + e.to + " " + e.code);
198
- }
199
- const extra = e.text ? "\\n" + e.text : "";
200
- return line(e.at, e.to + " " + e.subject + extra);
201
- }),
202
- );
203
- fill(
204
- "jobs",
205
- newest
206
- .filter((e) => e.kind === "job")
207
- .map((e) => {
208
- const err = e.error ? " " + e.error : "";
209
- return line(e.at, e.name + payloadText(e.payload) + err);
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
- "logs",
214
- newest.filter((e) => e.kind === "log").map((e) => line(e.at, e.message)),
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
- const events = await fetch("/_robodev/logs.json").then((r) => r.json());
220
- render(events);
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>