robodev 0.35.0 → 0.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "embedded-postgres": "17.10.0-beta.17",
27
27
  "fastify": "^5.12.1",
28
28
  "tsx": "^4.20.5",
29
- "@robodev-ai/runtime": "0.4.0",
29
+ "@robodev-ai/runtime": "0.4.1",
30
30
  "@robodev-ai/sdk": "0.11.0"
31
31
  },
32
32
  "devDependencies": {
@@ -77,6 +77,7 @@ export function devRouteClients(input: {
77
77
  enqueue: async (job) => {
78
78
  try {
79
79
  const result = await input.enqueue(job);
80
+ // Enqueue is not a run; the dashboard Jobs page reads `job_run` from onJobFinished.
80
81
  input.inspect.record({
81
82
  kind: "job",
82
83
  name: job.name,
@@ -1,9 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import {
4
+ buildDashboardState,
5
+ callMatchesRoute,
4
6
  createDevInspect,
5
7
  inspectHtml,
8
+ isHistogramExcludedPath,
6
9
  isInspectPath,
10
+ shouldCountRequestHistogram,
7
11
  type DevInspectAuthCodeEvent,
8
12
  type DevInspectEmailEvent,
9
13
  type DevInspectJobEvent,
@@ -11,9 +15,18 @@ import {
11
15
  type DevInspectRequestEvent,
12
16
  } from "./dev-inspect.js";
13
17
 
14
- test("isInspectPath excludes only inspect self-routes", () => {
18
+ const fileRuntime = {
19
+ routes: [{ method: "get", path: "/api/planets" }],
20
+ };
21
+
22
+ test("isInspectPath covers dashboard pages and JSON only", () => {
15
23
  assert.equal(isInspectPath("/_robodev"), true);
24
+ assert.equal(isInspectPath("/_robodev/apis"), true);
25
+ assert.equal(isInspectPath("/_robodev/jobs"), true);
26
+ assert.equal(isInspectPath("/_robodev/emails"), true);
27
+ assert.equal(isInspectPath("/_robodev/auth"), true);
16
28
  assert.equal(isInspectPath("/_robodev/logs"), true);
29
+ assert.equal(isInspectPath("/_robodev/state.json"), true);
17
30
  assert.equal(isInspectPath("/_robodev/logs.json"), true);
18
31
  assert.equal(isInspectPath("/_robodev/docs"), false);
19
32
  assert.equal(isInspectPath("/_robodev/ready"), false);
@@ -22,6 +35,23 @@ test("isInspectPath excludes only inspect self-routes", () => {
22
35
  assert.equal(isInspectPath("/api/planets"), false);
23
36
  });
24
37
 
38
+ test("shouldCountRequestHistogram counts Auth and matched file routes only", () => {
39
+ assert.equal(shouldCountRequestHistogram("/api/user/me", fileRuntime, "GET"), true);
40
+ assert.equal(shouldCountRequestHistogram("/api/user/auth/google", fileRuntime, "GET"), true);
41
+ assert.equal(shouldCountRequestHistogram("/api/planets", fileRuntime, "GET"), true);
42
+ assert.equal(shouldCountRequestHistogram("/api/planets", fileRuntime, "POST"), false);
43
+ assert.equal(shouldCountRequestHistogram("/api/unknown", fileRuntime, "GET"), false);
44
+ assert.equal(shouldCountRequestHistogram("/_robodev", fileRuntime, "GET"), false);
45
+ assert.equal(shouldCountRequestHistogram("/_robodev/apis", fileRuntime, "GET"), false);
46
+ assert.equal(shouldCountRequestHistogram("/_robodev/docs", fileRuntime, "GET"), false);
47
+ assert.equal(shouldCountRequestHistogram("/_robodev/scalar", fileRuntime, "GET"), false);
48
+ assert.equal(shouldCountRequestHistogram("/_robodev/ready", fileRuntime, "GET"), false);
49
+ assert.equal(shouldCountRequestHistogram("/openapi.json", fileRuntime, "GET"), false);
50
+ assert.equal(shouldCountRequestHistogram("/docs", fileRuntime, "GET"), false);
51
+ assert.equal(shouldCountRequestHistogram("/swagger", fileRuntime, "GET"), false);
52
+ assert.equal(isHistogramExcludedPath("/scalar"), true);
53
+ });
54
+
25
55
  test("ring buffer drops oldest events past max", () => {
26
56
  const inspect = createDevInspect({ max: 3 });
27
57
  inspect.record({ kind: "log", message: "a" });
@@ -37,6 +67,32 @@ test("ring buffer drops oldest events past max", () => {
37
67
  );
38
68
  });
39
69
 
70
+ test("request histogram survives 500-event ring rotation", () => {
71
+ const inspect = createDevInspect({ max: 2 });
72
+ inspect.incrementRequestHistogram(200);
73
+ inspect.incrementRequestHistogram(200);
74
+ inspect.incrementRequestHistogram(404);
75
+ inspect.incrementRequestHistogram(500);
76
+ inspect.record({ kind: "request", method: "GET", path: "/api/a", status: 200, durationMs: 1 });
77
+ inspect.record({ kind: "request", method: "GET", path: "/api/b", status: 200, durationMs: 1 });
78
+ inspect.record({ kind: "request", method: "GET", path: "/api/c", status: 200, durationMs: 1 });
79
+ assert.equal(inspect.list().length, 2);
80
+
81
+ const last = inspect.requestHistogram().at(-1);
82
+ assert.equal(last?.c2xx, 2);
83
+ assert.equal(last?.c4xx, 1);
84
+ assert.equal(last?.c5xx, 1);
85
+ });
86
+
87
+ test("3xx does not increment request histogram buckets", () => {
88
+ const inspect = createDevInspect();
89
+ inspect.incrementRequestHistogram(302);
90
+ const last = inspect.requestHistogram().at(-1);
91
+ assert.equal(last?.c2xx, 0);
92
+ assert.equal(last?.c4xx, 0);
93
+ assert.equal(last?.c5xx, 0);
94
+ });
95
+
40
96
  test("list is newest last and does not leak the internal buffer", () => {
41
97
  const inspect = createDevInspect({ max: 500 });
42
98
  inspect.record({ kind: "log", message: "first" });
@@ -112,12 +168,85 @@ test("record assigns id, at, and the expected fields for each kind", () => {
112
168
  assert.equal(inspect.list().length, 5);
113
169
  });
114
170
 
115
- test("inspect HTML has four lists and polls logs.json", () => {
171
+ test("callMatchesRoute uses matchPathParams and requires method", () => {
172
+ const route = { method: "GET", path: "/api/planets/:id" };
173
+ assert.equal(callMatchesRoute({ method: "GET", path: "/api/planets/123" }, route), true);
174
+ assert.equal(callMatchesRoute({ method: "GET", path: "/api/planets" }, route), false);
175
+ assert.equal(callMatchesRoute({ method: "POST", path: "/api/planets/123" }, route), false);
176
+ });
177
+
178
+ test("dashboard snapshot lists reserved Auth, filters emails, and ignores enqueue jobs", async () => {
179
+ const inspect = createDevInspect();
180
+ inspect.record({
181
+ kind: "request",
182
+ method: "GET",
183
+ path: "/api/planets",
184
+ status: 200,
185
+ durationMs: 4,
186
+ });
187
+ inspect.record({
188
+ kind: "request",
189
+ method: "GET",
190
+ path: "/_robodev/docs",
191
+ status: 200,
192
+ durationMs: 1,
193
+ });
194
+ inspect.record({ kind: "email", to: "a@b.test", subject: "Hello", text: "Body" });
195
+ inspect.record({ kind: "auth_code", type: "magic", to: "a@b.test", code: "999111" });
196
+ inspect.record({ kind: "job", name: "hourly", payload: { n: 1 }, error: "" });
197
+ inspect.record({
198
+ kind: "job_run",
199
+ name: "hourly",
200
+ status: "failed",
201
+ payload: { n: 1 },
202
+ error: "boom",
203
+ });
204
+ inspect.incrementJobHistogram("failed");
205
+
206
+ const state = await buildDashboardState({
207
+ inspect,
208
+ runtime: {
209
+ routes: [{ method: "get", path: "/api/planets", file: "api/planets.ts" }],
210
+ jobs: [{ name: "hourly", file: "jobs/hourly.ts", def: { cron: "0 * * * *" } }],
211
+ },
212
+ });
213
+
214
+ assert.equal(state.overview.apiCount, 1 + 12);
215
+ assert.equal(state.overview.jobCount, 1);
216
+ assert.equal(state.overview.google, "broker");
217
+ assert.ok(state.routes.some((route) => route.path === "/api/user/auth/google/callback"));
218
+ assert.equal(state.calls.length, 1);
219
+ assert.equal(state.calls[0]?.path, "/api/planets");
220
+ assert.equal(state.emails.length, 1);
221
+ assert.equal(state.emails[0]?.subject, "Hello");
222
+ assert.equal(state.jobRuns.length, 1);
223
+ assert.equal(state.jobRuns[0]?.status, "failed");
224
+ assert.equal(state.overview.jobHistogram.at(-1)?.failed, 1);
225
+ assert.deepEqual(state.auth.users, []);
226
+ });
227
+
228
+ test("incomplete Google credentials are not labeled override", async () => {
229
+ const state = await buildDashboardState({
230
+ inspect: createDevInspect(),
231
+ runtime: { routes: [], jobs: [] },
232
+ googleClientId: "only-id",
233
+ });
234
+ assert.equal(state.overview.google, "incomplete");
235
+ assert.equal(state.auth.google, "incomplete");
236
+ });
237
+
238
+ test("inspect HTML has nav labels and polls state.json", () => {
116
239
  const html = inspectHtml();
117
- assert.match(html, /<h2>Requests<\/h2>/);
118
- assert.match(html, /<h2>Emails<\/h2>/);
119
- assert.match(html, /<h2>Jobs<\/h2>/);
120
- assert.match(html, /<h2>Logs<\/h2>/);
121
- assert.match(html, /\/_robodev\/logs\.json/);
240
+ assert.match(html, />Overview</);
241
+ assert.match(html, />APIs</);
242
+ assert.match(html, />Jobs</);
243
+ assert.match(html, />Emails</);
244
+ assert.match(html, />Auth</);
245
+ assert.doesNotMatch(html, /<h2>Requests<\/h2>/);
246
+ assert.doesNotMatch(html, /<h2>Emails<\/h2>/);
247
+ assert.doesNotMatch(html, /<h2>Jobs<\/h2>/);
248
+ assert.doesNotMatch(html, /<h2>Logs<\/h2>/);
249
+ assert.match(html, /\/_robodev\/state\.json/);
250
+ assert.doesNotMatch(html, /fetch\("\/_robodev\/logs\.json"\)/);
122
251
  assert.match(html, /setInterval\(poll, 1000\)/);
123
252
  });
@@ -1,8 +1,53 @@
1
+ import {
2
+ isLocalAuthPath,
3
+ matchPathParams,
4
+ matchProjectRoute,
5
+ projectGoogleOverrideMode,
6
+ } from "@robodev-ai/runtime";
7
+
1
8
  export const DEV_INSPECT_MAX = 500;
9
+ export const HISTOGRAM_MINUTES = 120;
10
+
11
+ const DASHBOARD_HTML_PATHS = new Set([
12
+ "/_robodev",
13
+ "/_robodev/apis",
14
+ "/_robodev/jobs",
15
+ "/_robodev/emails",
16
+ "/_robodev/auth",
17
+ "/_robodev/logs",
18
+ ]);
19
+
20
+ const DASHBOARD_JSON_PATHS = new Set(["/_robodev/state.json", "/_robodev/logs.json"]);
2
21
 
3
- const INSPECT_PATHS = new Set(["/_robodev", "/_robodev/logs", "/_robodev/logs.json"]);
22
+ const HISTOGRAM_EXCLUDED_PATHS = new Set([
23
+ ...DASHBOARD_HTML_PATHS,
24
+ ...DASHBOARD_JSON_PATHS,
25
+ "/_robodev/docs",
26
+ "/_robodev/swagger",
27
+ "/docs",
28
+ "/swagger",
29
+ "/_robodev/scalar",
30
+ "/scalar",
31
+ "/openapi.json",
32
+ "/_robodev/ready",
33
+ ]);
4
34
 
5
- export type DevInspectKind = "request" | "email" | "auth_code" | "job" | "log";
35
+ export const RESERVED_AUTH_ROUTES: Array<{ method: string; path: string; file: string }> = [
36
+ { method: "POST", path: "/api/user/auth/register", file: "reserved" },
37
+ { method: "POST", path: "/api/user/auth/login", file: "reserved" },
38
+ { method: "POST", path: "/api/user/auth/refresh", file: "reserved" },
39
+ { method: "GET", path: "/api/user/auth/magic-link", file: "reserved" },
40
+ { method: "GET", path: "/api/user/auth/magic-link/callback", file: "reserved" },
41
+ { method: "POST", path: "/api/user/auth/forgot-password", file: "reserved" },
42
+ { method: "POST", path: "/api/user/auth/forgot-password/callback", file: "reserved" },
43
+ { method: "GET", path: "/api/user/auth/google", file: "reserved" },
44
+ { method: "GET", path: "/api/user/auth/google/callback", file: "reserved" },
45
+ { method: "GET", path: "/api/user/auth/apple/callback", file: "reserved" },
46
+ { method: "GET", path: "/api/user/me", file: "reserved" },
47
+ { method: "PUT", path: "/api/user/me", file: "reserved" },
48
+ ];
49
+
50
+ export type DevInspectKind = "request" | "email" | "auth_code" | "job" | "job_run" | "log";
6
51
 
7
52
  type EventBase = {
8
53
  id: string;
@@ -38,6 +83,14 @@ export type DevInspectJobEvent = EventBase & {
38
83
  error: string;
39
84
  };
40
85
 
86
+ export type DevInspectJobRunEvent = EventBase & {
87
+ kind: "job_run";
88
+ name: string;
89
+ status: "succeeded" | "failed";
90
+ payload?: unknown;
91
+ error?: string;
92
+ };
93
+
41
94
  export type DevInspectLogEvent = EventBase & {
42
95
  kind: "log";
43
96
  message: string;
@@ -48,6 +101,7 @@ export type DevInspectEvent =
48
101
  | DevInspectEmailEvent
49
102
  | DevInspectAuthCodeEvent
50
103
  | DevInspectJobEvent
104
+ | DevInspectJobRunEvent
51
105
  | DevInspectLogEvent;
52
106
 
53
107
  export type DevInspectRecordInput =
@@ -55,22 +109,118 @@ export type DevInspectRecordInput =
55
109
  | Omit<DevInspectEmailEvent, "id" | "at">
56
110
  | Omit<DevInspectAuthCodeEvent, "id" | "at">
57
111
  | Omit<DevInspectJobEvent, "id" | "at">
112
+ | Omit<DevInspectJobRunEvent, "id" | "at">
58
113
  | Omit<DevInspectLogEvent, "id" | "at">;
59
114
 
115
+ export type DashboardGoogle = "broker" | "local_override" | "incomplete";
116
+
117
+ export type RequestHistogramBucket = { t: number; c2xx: number; c4xx: number; c5xx: number };
118
+ export type JobHistogramBucket = { t: number; succeeded: number; failed: number };
119
+
120
+ export type DashboardState = {
121
+ overview: {
122
+ apiCount: number;
123
+ jobCount: number;
124
+ google: DashboardGoogle;
125
+ requestHistogram: RequestHistogramBucket[];
126
+ jobHistogram: JobHistogramBucket[];
127
+ links: { swagger: string; scalar: string; openapi: string };
128
+ };
129
+ routes: Array<{ method: string; path: string; file: string }>;
130
+ calls: Array<{ at: number; method: string; path: string; status: number; durationMs: number }>;
131
+ jobs: Array<{ name: string; cron: string | null; file: string }>;
132
+ jobRuns: Array<{
133
+ at: number;
134
+ name: string;
135
+ status: "succeeded" | "failed";
136
+ payload?: unknown;
137
+ error?: string;
138
+ }>;
139
+ emails: Array<{ at: number; to: string; subject: string; text?: string }>;
140
+ auth: {
141
+ google: DashboardGoogle;
142
+ users: Array<{ id: string; email: string; name: string | null; createdAt: string }>;
143
+ };
144
+ };
145
+
146
+ export type DashboardRuntime = {
147
+ routes: Array<{ method: string; path: string; file: string }>;
148
+ jobs: Array<{ name: string; file: string; def: { cron?: string } }>;
149
+ pool?: {
150
+ query: <R extends Record<string, unknown> = Record<string, unknown>>(
151
+ sql: string,
152
+ values?: unknown[],
153
+ ) => Promise<{ rows: R[] }>;
154
+ };
155
+ } | null;
156
+
60
157
  export type DevInspectStore = {
61
158
  record: (input: DevInspectRecordInput) => DevInspectEvent;
62
159
  list: () => DevInspectEvent[];
160
+ incrementRequestHistogram: (status: number, at?: number) => void;
161
+ incrementJobHistogram: (outcome: "succeeded" | "failed", at?: number) => void;
162
+ requestHistogram: (now?: number) => RequestHistogramBucket[];
163
+ jobHistogram: (now?: number) => JobHistogramBucket[];
63
164
  html: () => string;
64
165
  };
65
166
 
66
- /** Inspect HTML/JSON self-routes — do not print or record these as requests. */
167
+ /** Dashboard HTML/JSON self-routes — do not print or record these as requests. */
67
168
  export function isInspectPath(pathname: string): boolean {
68
- return INSPECT_PATHS.has(pathname);
169
+ return DASHBOARD_HTML_PATHS.has(pathname) || DASHBOARD_JSON_PATHS.has(pathname);
170
+ }
171
+
172
+ export function isHistogramExcludedPath(pathname: string): boolean {
173
+ return HISTOGRAM_EXCLUDED_PATHS.has(pathname);
174
+ }
175
+
176
+ /** Project file routes and `/api/user…` only. Unmatched 404s and reserved tools are out. */
177
+ export function shouldCountRequestHistogram(
178
+ pathname: string,
179
+ runtime: { routes: Array<{ method: string; path: string }> } | null,
180
+ method = "GET",
181
+ ): boolean {
182
+ if (isHistogramExcludedPath(pathname)) return false;
183
+ if (isLocalAuthPath(pathname)) return true;
184
+ if (!runtime) return false;
185
+ return matchProjectRoute(pathname, method, runtime.routes) !== null;
186
+ }
187
+
188
+ export function callMatchesRoute(
189
+ call: { method: string; path: string },
190
+ route: { method: string; path: string },
191
+ ): boolean {
192
+ if (call.method.toUpperCase() !== route.method.toUpperCase()) return false;
193
+ if (matchPathParams(route.path, call.path)) return true;
194
+ if (route.path.startsWith("/api/") || route.path === "/api") return false;
195
+ const prefixed = route.path === "/" ? "/api" : `/api${route.path}`;
196
+ return matchPathParams(prefixed, call.path) !== null;
197
+ }
198
+
199
+ export function googleDashboardStatus(mode: "both" | "one" | "none"): DashboardGoogle {
200
+ if (mode === "both") return "local_override";
201
+ if (mode === "one") return "incomplete";
202
+ return "broker";
203
+ }
204
+
205
+ function minuteOf(at: number): number {
206
+ return Math.floor(at / 60_000);
207
+ }
208
+
209
+ function pruneMap<T>(map: Map<number, T>, nowMinute: number): void {
210
+ const oldest = nowMinute - HISTOGRAM_MINUTES + 1;
211
+ for (const key of map.keys()) {
212
+ if (key < oldest) map.delete(key);
213
+ }
69
214
  }
70
215
 
71
216
  export function createDevInspect(options?: { max?: number }): DevInspectStore {
72
217
  const max = options?.max ?? DEV_INSPECT_MAX;
73
218
  const events: DevInspectEvent[] = [];
219
+ const requestBuckets = new Map<
220
+ number,
221
+ { minute: number; c2xx: number; c4xx: number; c5xx: number }
222
+ >();
223
+ const jobBuckets = new Map<number, { minute: number; succeeded: number; failed: number }>();
74
224
  let nextId = 1;
75
225
 
76
226
  function record(input: DevInspectRecordInput): DevInspectEvent {
@@ -86,142 +236,571 @@ export function createDevInspect(options?: { max?: number }): DevInspectStore {
86
236
  return events.slice();
87
237
  }
88
238
 
89
- return { record, list, html: inspectHtml };
239
+ function incrementRequestHistogram(status: number, at = Date.now()): void {
240
+ if (status < 200 || (status >= 300 && status < 400) || status >= 600) return;
241
+ const minute = minuteOf(at);
242
+ pruneMap(requestBuckets, minute);
243
+ let bucket = requestBuckets.get(minute);
244
+ if (!bucket) {
245
+ bucket = { minute, c2xx: 0, c4xx: 0, c5xx: 0 };
246
+ requestBuckets.set(minute, bucket);
247
+ }
248
+ if (status < 300) bucket.c2xx += 1;
249
+ else if (status < 500) bucket.c4xx += 1;
250
+ else bucket.c5xx += 1;
251
+ }
252
+
253
+ function incrementJobHistogram(outcome: "succeeded" | "failed", at = Date.now()): void {
254
+ const minute = minuteOf(at);
255
+ pruneMap(jobBuckets, minute);
256
+ let bucket = jobBuckets.get(minute);
257
+ if (!bucket) {
258
+ bucket = { minute, succeeded: 0, failed: 0 };
259
+ jobBuckets.set(minute, bucket);
260
+ }
261
+ bucket[outcome] += 1;
262
+ }
263
+
264
+ function requestHistogram(now = Date.now()): RequestHistogramBucket[] {
265
+ const nowMinute = minuteOf(now);
266
+ pruneMap(requestBuckets, nowMinute);
267
+ const out: RequestHistogramBucket[] = [];
268
+ for (let i = HISTOGRAM_MINUTES - 1; i >= 0; i--) {
269
+ const minute = nowMinute - i;
270
+ const bucket = requestBuckets.get(minute);
271
+ out.push({
272
+ t: minute * 60_000,
273
+ c2xx: bucket?.c2xx ?? 0,
274
+ c4xx: bucket?.c4xx ?? 0,
275
+ c5xx: bucket?.c5xx ?? 0,
276
+ });
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function jobHistogram(now = Date.now()): JobHistogramBucket[] {
282
+ const nowMinute = minuteOf(now);
283
+ pruneMap(jobBuckets, nowMinute);
284
+ const out: JobHistogramBucket[] = [];
285
+ for (let i = HISTOGRAM_MINUTES - 1; i >= 0; i--) {
286
+ const minute = nowMinute - i;
287
+ const bucket = jobBuckets.get(minute);
288
+ out.push({
289
+ t: minute * 60_000,
290
+ succeeded: bucket?.succeeded ?? 0,
291
+ failed: bucket?.failed ?? 0,
292
+ });
293
+ }
294
+ return out;
295
+ }
296
+
297
+ return {
298
+ record,
299
+ list,
300
+ incrementRequestHistogram,
301
+ incrementJobHistogram,
302
+ requestHistogram,
303
+ jobHistogram,
304
+ html: inspectHtml,
305
+ };
306
+ }
307
+
308
+ async function loadAuthUsers(
309
+ pool: NonNullable<DashboardRuntime>["pool"],
310
+ ): Promise<DashboardState["auth"]["users"]> {
311
+ if (!pool) return [];
312
+ try {
313
+ const result = await pool.query<{
314
+ id: string;
315
+ email: string;
316
+ name: string | null;
317
+ created_at: Date | string;
318
+ }>(`SELECT id, email, name, created_at FROM robodev_auth.users ORDER BY created_at DESC`);
319
+ return result.rows.map((row) => ({
320
+ id: row.id,
321
+ email: row.email,
322
+ name: row.name ?? null,
323
+ createdAt:
324
+ row.created_at instanceof Date
325
+ ? row.created_at.toISOString()
326
+ : String(row.created_at ?? ""),
327
+ }));
328
+ } catch {
329
+ return [];
330
+ }
331
+ }
332
+
333
+ export async function buildDashboardState(input: {
334
+ inspect: DevInspectStore;
335
+ runtime: DashboardRuntime;
336
+ googleClientId?: string;
337
+ googleClientSecret?: string;
338
+ }): Promise<DashboardState> {
339
+ const google = googleDashboardStatus(
340
+ projectGoogleOverrideMode(input.googleClientId, input.googleClientSecret),
341
+ );
342
+ const fileRoutes = (input.runtime?.routes ?? []).map((route) => ({
343
+ method: route.method.toUpperCase(),
344
+ path: route.path,
345
+ file: route.file,
346
+ }));
347
+ const routes = [...fileRoutes, ...RESERVED_AUTH_ROUTES];
348
+ const newest = input.inspect.list().slice().reverse();
349
+ const calls = newest
350
+ .filter((event): event is DevInspectRequestEvent => event.kind === "request")
351
+ .filter((event) => !isHistogramExcludedPath(event.path))
352
+ .map((event) => ({
353
+ at: event.at,
354
+ method: event.method,
355
+ path: event.path,
356
+ status: event.status,
357
+ durationMs: event.durationMs,
358
+ }));
359
+ const jobRuns = newest
360
+ .filter((event): event is DevInspectJobRunEvent => event.kind === "job_run")
361
+ .map((event) => ({
362
+ at: event.at,
363
+ name: event.name,
364
+ status: event.status,
365
+ payload: event.payload,
366
+ error: event.error,
367
+ }));
368
+ const emails = newest
369
+ .filter((event): event is DevInspectEmailEvent => event.kind === "email")
370
+ .map((event) => ({
371
+ at: event.at,
372
+ to: event.to,
373
+ subject: event.subject,
374
+ text: event.text,
375
+ }));
376
+ const jobs = (input.runtime?.jobs ?? []).map((job) => ({
377
+ name: job.name,
378
+ cron: job.def.cron ?? null,
379
+ file: job.file,
380
+ }));
381
+
382
+ return {
383
+ overview: {
384
+ apiCount: routes.length,
385
+ jobCount: jobs.length,
386
+ google,
387
+ requestHistogram: input.inspect.requestHistogram(),
388
+ jobHistogram: input.inspect.jobHistogram(),
389
+ links: { swagger: "/_robodev/docs", scalar: "/_robodev/scalar", openapi: "/openapi.json" },
390
+ },
391
+ routes,
392
+ calls,
393
+ jobs,
394
+ jobRuns,
395
+ emails,
396
+ auth: {
397
+ google,
398
+ users: await loadAuthUsers(input.runtime?.pool),
399
+ },
400
+ };
90
401
  }
91
402
 
92
- /** Inline inspect page — same pattern as runtime `swaggerHtml` (string template, no FE build). */
403
+ /** Inline dashboard — same pattern as runtime `swaggerHtml` (string template, no FE build). */
93
404
  export function inspectHtml(): string {
94
405
  return `<!doctype html>
95
406
  <html>
96
407
  <head>
97
408
  <meta charset="utf-8" />
98
409
  <meta name="viewport" content="width=device-width, initial-scale=1" />
99
- <title>Robodev Inspect</title>
410
+ <title>robodev dev</title>
100
411
  <style>
101
412
  :root { color-scheme: light dark; }
102
413
  body {
103
414
  margin: 0 auto;
104
- max-width: 960px;
105
- padding: 24px 20px 48px;
415
+ max-width: 1080px;
416
+ padding: 20px 18px 48px;
106
417
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
107
418
  line-height: 1.45;
108
419
  }
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; }
420
+ header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px 20px; margin-bottom: 20px; }
421
+ h1 { font-size: 1.15rem; font-weight: 650; margin: 0; }
422
+ nav { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.92rem; }
423
+ nav a { color: inherit; text-decoration: none; opacity: 0.7; }
424
+ nav a.current { opacity: 1; font-weight: 650; text-decoration: underline; text-underline-offset: 3px; }
425
+ .lead { color: #666; margin: 0 0 18px; font-size: 0.88rem; }
426
+ .cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
427
+ .card {
428
+ min-width: 140px;
429
+ flex: 1;
430
+ border: 1px solid #ddd;
431
+ border-radius: 8px;
432
+ padding: 10px 12px;
433
+ }
434
+ .card .label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; }
435
+ .card .value { font-size: 1.35rem; font-weight: 650; margin-top: 2px; }
436
+ .card .note { font-size: 0.82rem; margin-top: 4px; }
437
+ .links { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 20px; font-size: 0.9rem; }
438
+ .chart-wrap { margin: 0 0 18px; }
439
+ .chart-title { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; margin: 0 0 6px; }
440
+ .legend { font-size: 0.75rem; opacity: 0.75; margin: 0 0 8px; }
441
+ .legend span { margin-right: 10px; }
442
+ .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
443
+ .chart { display: flex; align-items: flex-end; gap: 1px; height: 88px; border-bottom: 1px solid #ddd; }
444
+ .bar { flex: 1; display: flex; flex-direction: column-reverse; height: 100%; min-width: 0; }
445
+ .seg { width: 100%; }
446
+ .c2xx, .sw-2xx { background: #3b82f6; }
447
+ .c4xx, .sw-4xx { background: #f59e0b; }
448
+ .c5xx, .sw-5xx { background: #ef4444; }
449
+ .ok, .sw-ok { background: #22c55e; }
450
+ .bad, .sw-bad { background: #ef4444; }
451
+ .tabs { display: flex; gap: 8px; margin: 0 0 12px; }
452
+ .tabs button, .clear {
453
+ font: inherit;
454
+ font-size: 0.85rem;
455
+ padding: 4px 10px;
456
+ border: 1px solid #ccc;
457
+ border-radius: 6px;
458
+ background: transparent;
459
+ color: inherit;
460
+ cursor: pointer;
461
+ }
462
+ .tabs button.active { font-weight: 650; border-color: currentColor; }
463
+ table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
464
+ th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
465
+ th { font-size: 0.72rem; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; font-family: inherit; }
466
+ tr.clickable { cursor: pointer; }
467
+ tr.clickable:hover td { background: color-mix(in srgb, currentColor 6%, transparent); }
468
+ .empty { color: #888; font-family: inherit; padding: 10px 0; }
469
+ .filter { font-size: 0.85rem; margin: 0 0 10px; display: flex; align-items: center; gap: 10px; }
470
+ .ok-text { color: #16a34a; }
471
+ .bad-text { color: #dc2626; }
472
+ .page { display: none; }
473
+ .page.visible { display: block; }
129
474
  @media (prefers-color-scheme: dark) {
130
- .lead, .empty, .meta { color: #9aa0a6; }
131
- ul { border-top-color: #333; }
132
- li { border-bottom-color: #2a2a2a; }
475
+ .lead, .empty, .card .label, .chart-title, .legend { color: #9aa0a6; }
476
+ .card, .chart { border-color: #333; }
477
+ th, td { border-bottom-color: #2a2a2a; }
478
+ .tabs button, .clear { border-color: #555; }
133
479
  }
134
480
  </style>
135
481
  </head>
136
482
  <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>
483
+ <header>
484
+ <h1>robodev dev</h1>
485
+ <nav>
486
+ <a href="/_robodev" data-nav="overview">Overview</a>
487
+ <a href="/_robodev/apis" data-nav="apis">APIs</a>
488
+ <a href="/_robodev/jobs" data-nav="jobs">Jobs</a>
489
+ <a href="/_robodev/emails" data-nav="emails">Emails</a>
490
+ <a href="/_robodev/auth" data-nav="auth">Auth</a>
491
+ </nav>
492
+ </header>
493
+ <p class="lead">Local dashboard. Readonly. Polls every 1s.</p>
494
+
495
+ <section id="page-overview" class="page">
496
+ <div class="cards">
497
+ <div class="card"><div class="label">APIs</div><div class="value" id="api-count">0</div></div>
498
+ <div class="card"><div class="label">Jobs</div><div class="value" id="job-count">0</div></div>
499
+ <div class="card"><div class="label">Auth</div><div class="note" id="google-line"></div></div>
500
+ </div>
501
+ <div class="links" id="doc-links"></div>
502
+ <div class="chart-wrap">
503
+ <p class="chart-title">Requests (2 hours)</p>
504
+ <p class="legend">
505
+ <span><i class="swatch sw-2xx"></i>2xx</span>
506
+ <span><i class="swatch sw-4xx"></i>4xx</span>
507
+ <span><i class="swatch sw-5xx"></i>5xx</span>
508
+ </p>
509
+ <div class="chart" id="req-chart"></div>
510
+ </div>
511
+ <div class="chart-wrap">
512
+ <p class="chart-title">Jobs (2 hours)</p>
513
+ <p class="legend">
514
+ <span><i class="swatch sw-ok"></i>succeeded</span>
515
+ <span><i class="swatch sw-bad"></i>failed</span>
516
+ </p>
517
+ <div class="chart" id="job-chart"></div>
518
+ </div>
519
+ </section>
520
+
521
+ <section id="page-apis" class="page">
522
+ <div class="tabs">
523
+ <button type="button" data-tab="routes">Routes</button>
524
+ <button type="button" data-tab="calls">Calls</button>
525
+ </div>
526
+ <div id="apis-filter" class="filter" hidden></div>
527
+ <div id="apis-routes"></div>
528
+ <div id="apis-calls" hidden></div>
529
+ </section>
530
+
531
+ <section id="page-jobs" class="page">
532
+ <p class="chart-title">Registered</p>
533
+ <div id="jobs-defs"></div>
534
+ <p class="chart-title" style="margin-top:18px">Runs</p>
535
+ <div id="jobs-runs"></div>
536
+ </section>
537
+
538
+ <section id="page-emails" class="page">
539
+ <div id="emails-list"></div>
540
+ </section>
541
+
542
+ <section id="page-auth" class="page">
543
+ <p class="lead" id="auth-google"></p>
544
+ <div id="auth-users"></div>
545
+ </section>
546
+
147
547
  <script>
548
+ var PAGES = {
549
+ "/_robodev": "overview",
550
+ "/_robodev/apis": "apis",
551
+ "/_robodev/jobs": "jobs",
552
+ "/_robodev/emails": "emails",
553
+ "/_robodev/auth": "auth"
554
+ };
555
+ var apisTab = "routes";
556
+ var callFilter = null;
557
+ var lastState = null;
558
+
559
+ function pagePath() {
560
+ return location.pathname.replace(/\\/$/, "") || "/";
561
+ }
562
+ function currentPage() {
563
+ return PAGES[pagePath()] || "overview";
564
+ }
565
+ function matchPathParams(pattern, pathname) {
566
+ var patternParts = pattern.split("/").filter(Boolean);
567
+ var pathParts = pathname.split("/").filter(Boolean);
568
+ if (patternParts.length !== pathParts.length) return null;
569
+ var params = {};
570
+ for (var i = 0; i < patternParts.length; i++) {
571
+ var expected = patternParts[i];
572
+ var actual = pathParts[i];
573
+ if (expected.charAt(0) === ":") {
574
+ try { params[expected.slice(1)] = decodeURIComponent(actual); }
575
+ catch (e) { params[expected.slice(1)] = actual; }
576
+ continue;
577
+ }
578
+ if (expected !== actual) return null;
579
+ }
580
+ return params;
581
+ }
582
+ function callMatches(call, route) {
583
+ if (String(call.method).toUpperCase() !== String(route.method).toUpperCase()) return false;
584
+ if (matchPathParams(route.path, call.path)) return true;
585
+ if (route.path.indexOf("/api/") === 0 || route.path === "/api") return false;
586
+ var prefixed = route.path === "/" ? "/api" : "/api" + route.path;
587
+ return matchPathParams(prefixed, call.path) !== null;
588
+ }
148
589
  function timeLabel(at) {
149
590
  return new Date(at).toLocaleTimeString();
150
591
  }
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;
592
+ function googleLabel(mode) {
593
+ if (mode === "local_override") return "Local Google override";
594
+ if (mode === "incomplete") return "Google credentials incomplete (not override; Google is 503)";
595
+ return "Starbase identity broker";
596
+ }
597
+ function emptyNode() {
598
+ var p = document.createElement("p");
599
+ p.className = "empty";
600
+ p.textContent = "None yet";
601
+ return p;
602
+ }
603
+ function fill(el, node) {
604
+ el.replaceChildren();
605
+ el.appendChild(node);
606
+ }
607
+ function table(headers, rows, onRow) {
608
+ if (!rows.length) return emptyNode();
609
+ var tableEl = document.createElement("table");
610
+ var thead = document.createElement("thead");
611
+ var hr = document.createElement("tr");
612
+ for (var i = 0; i < headers.length; i++) {
613
+ var th = document.createElement("th");
614
+ th.textContent = headers[i];
615
+ hr.appendChild(th);
172
616
  }
173
- for (const row of rows) ul.appendChild(row);
617
+ thead.appendChild(hr);
618
+ tableEl.appendChild(thead);
619
+ var tbody = document.createElement("tbody");
620
+ for (var r = 0; r < rows.length; r++) {
621
+ var tr = document.createElement("tr");
622
+ if (onRow) {
623
+ tr.className = "clickable";
624
+ (function (row) {
625
+ tr.addEventListener("click", function () { onRow(row); });
626
+ })(rows[r]);
627
+ }
628
+ var cells = rows[r].cells;
629
+ for (var c = 0; c < cells.length; c++) {
630
+ var td = document.createElement("td");
631
+ if (cells[c] && cells[c].html) td.innerHTML = cells[c].html;
632
+ else td.textContent = cells[c] == null ? "" : String(cells[c]);
633
+ tr.appendChild(td);
634
+ }
635
+ tbody.appendChild(tr);
636
+ }
637
+ tableEl.appendChild(tbody);
638
+ return tableEl;
174
639
  }
175
640
  function payloadText(payload) {
176
641
  if (payload === undefined) return "";
177
- try {
178
- return " " + JSON.stringify(payload);
179
- } catch {
180
- return " [unserializable payload]";
642
+ try { return JSON.stringify(payload); }
643
+ catch (e) { return "[unserializable payload]"; }
644
+ }
645
+ function renderChart(el, buckets, series) {
646
+ el.replaceChildren();
647
+ var max = 1;
648
+ for (var i = 0; i < buckets.length; i++) {
649
+ var total = 0;
650
+ for (var s = 0; s < series.length; s++) total += buckets[i][series[s].key] || 0;
651
+ if (total > max) max = total;
652
+ }
653
+ for (var i = 0; i < buckets.length; i++) {
654
+ var col = document.createElement("div");
655
+ col.className = "bar";
656
+ for (var s = 0; s < series.length; s++) {
657
+ var n = buckets[i][series[s].key] || 0;
658
+ if (!n) continue;
659
+ var seg = document.createElement("div");
660
+ seg.className = "seg " + series[s].cls;
661
+ seg.style.height = ((n / max) * 100) + "%";
662
+ col.appendChild(seg);
663
+ }
664
+ el.appendChild(col);
665
+ }
666
+ }
667
+ function markNav() {
668
+ var page = currentPage();
669
+ var links = document.querySelectorAll("nav a");
670
+ for (var i = 0; i < links.length; i++) {
671
+ links[i].className = links[i].getAttribute("data-nav") === page ? "current" : "";
672
+ }
673
+ var sections = document.querySelectorAll(".page");
674
+ for (var i = 0; i < sections.length; i++) {
675
+ sections[i].className = sections[i].id === "page-" + page ? "page visible" : "page";
181
676
  }
182
677
  }
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
- }),
211
- );
212
- fill(
213
- "logs",
214
- newest.filter((e) => e.kind === "log").map((e) => line(e.at, e.message)),
215
- );
678
+ function setApisTab(tab) {
679
+ apisTab = tab;
680
+ document.querySelector('[data-tab="routes"]').className = tab === "routes" ? "active" : "";
681
+ document.querySelector('[data-tab="calls"]').className = tab === "calls" ? "active" : "";
682
+ document.getElementById("apis-routes").hidden = tab !== "routes";
683
+ document.getElementById("apis-calls").hidden = tab !== "calls";
216
684
  }
685
+ function render(state) {
686
+ lastState = state;
687
+ markNav();
688
+ document.getElementById("api-count").textContent = String(state.overview.apiCount);
689
+ document.getElementById("job-count").textContent = String(state.overview.jobCount);
690
+ document.getElementById("google-line").textContent = googleLabel(state.overview.google);
691
+ var links = document.getElementById("doc-links");
692
+ links.replaceChildren();
693
+ var items = [
694
+ { href: state.overview.links.swagger, label: "Swagger" },
695
+ { href: state.overview.links.scalar, label: "Scalar" },
696
+ { href: state.overview.links.openapi, label: "openapi.json" }
697
+ ];
698
+ for (var i = 0; i < items.length; i++) {
699
+ var a = document.createElement("a");
700
+ a.href = items[i].href;
701
+ a.textContent = items[i].label;
702
+ links.appendChild(a);
703
+ }
704
+ renderChart(document.getElementById("req-chart"), state.overview.requestHistogram, [
705
+ { key: "c2xx", cls: "c2xx" },
706
+ { key: "c4xx", cls: "c4xx" },
707
+ { key: "c5xx", cls: "c5xx" }
708
+ ]);
709
+ renderChart(document.getElementById("job-chart"), state.overview.jobHistogram, [
710
+ { key: "succeeded", cls: "ok" },
711
+ { key: "failed", cls: "bad" }
712
+ ]);
713
+
714
+ fill(document.getElementById("apis-routes"), table(
715
+ ["Method", "Path", "File"],
716
+ state.routes.map(function (route) {
717
+ return { cells: [route.method, route.path, route.file], route: route };
718
+ }),
719
+ function (row) {
720
+ callFilter = { method: row.route.method, path: row.route.path };
721
+ setApisTab("calls");
722
+ render(lastState);
723
+ }
724
+ ));
725
+ var filtered = state.calls;
726
+ if (callFilter) {
727
+ filtered = state.calls.filter(function (call) { return callMatches(call, callFilter); });
728
+ }
729
+ var filterEl = document.getElementById("apis-filter");
730
+ if (callFilter) {
731
+ filterEl.hidden = false;
732
+ filterEl.replaceChildren();
733
+ var label = document.createElement("span");
734
+ label.textContent = "Filtered to " + callFilter.method + " " + callFilter.path;
735
+ var clear = document.createElement("button");
736
+ clear.type = "button";
737
+ clear.className = "clear";
738
+ clear.textContent = "Clear filter";
739
+ clear.addEventListener("click", function () {
740
+ callFilter = null;
741
+ render(lastState);
742
+ });
743
+ filterEl.appendChild(label);
744
+ filterEl.appendChild(clear);
745
+ } else {
746
+ filterEl.hidden = true;
747
+ filterEl.replaceChildren();
748
+ }
749
+ fill(document.getElementById("apis-calls"), table(
750
+ ["Time", "Method", "Path", "Status", "Duration"],
751
+ filtered.map(function (call) {
752
+ return { cells: [timeLabel(call.at), call.method, call.path, call.status, call.durationMs + "ms"] };
753
+ })
754
+ ));
755
+ setApisTab(apisTab);
756
+
757
+ fill(document.getElementById("jobs-defs"), table(
758
+ ["Name", "Cron", "File"],
759
+ state.jobs.map(function (job) {
760
+ return { cells: [job.name, job.cron || "", job.file] };
761
+ })
762
+ ));
763
+ fill(document.getElementById("jobs-runs"), table(
764
+ ["Time", "Name", "Status", "Payload", "Error"],
765
+ state.jobRuns.map(function (run) {
766
+ var status = run.status === "succeeded"
767
+ ? { html: '<span class="ok-text">succeeded</span>' }
768
+ : { html: '<span class="bad-text">failed</span>' };
769
+ return { cells: [timeLabel(run.at), run.name, status, payloadText(run.payload), run.error || ""] };
770
+ })
771
+ ));
772
+
773
+ fill(document.getElementById("emails-list"), table(
774
+ ["Time", "To", "Subject", "Body"],
775
+ state.emails.map(function (email) {
776
+ return { cells: [timeLabel(email.at), email.to, email.subject, email.text || ""] };
777
+ })
778
+ ));
779
+
780
+ document.getElementById("auth-google").textContent = googleLabel(state.auth.google);
781
+ fill(document.getElementById("auth-users"), table(
782
+ ["Id", "Email", "Name", "Created"],
783
+ state.auth.users.map(function (user) {
784
+ return { cells: [user.id, user.email, user.name || "", user.createdAt] };
785
+ })
786
+ ));
787
+ }
788
+ document.querySelector('[data-tab="routes"]').addEventListener("click", function () {
789
+ setApisTab("routes");
790
+ });
791
+ document.querySelector('[data-tab="calls"]').addEventListener("click", function () {
792
+ callFilter = null;
793
+ setApisTab("calls");
794
+ if (lastState) render(lastState);
795
+ });
217
796
  async function poll() {
218
797
  try {
219
- const events = await fetch("/_robodev/logs.json").then((r) => r.json());
220
- render(events);
221
- } catch {
222
- /* keep last render */
223
- }
798
+ var state = await fetch("/_robodev/state.json").then(function (r) { return r.json(); });
799
+ render(state);
800
+ } catch (e) { /* keep last render */ }
224
801
  }
802
+ markNav();
803
+ setApisTab("routes");
225
804
  poll();
226
805
  setInterval(poll, 1000);
227
806
  </script>
package/src/dev-server.ts CHANGED
@@ -32,7 +32,12 @@ import type {
32
32
  } from "@robodev-ai/sdk";
33
33
  import type { RuntimeJob } from "@robodev-ai/runtime";
34
34
  import { devRouteClients } from "./dev-clients.js";
35
- import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
35
+ import {
36
+ buildDashboardState,
37
+ isInspectPath,
38
+ shouldCountRequestHistogram,
39
+ type DevInspectStore,
40
+ } from "./dev-inspect.js";
36
41
 
37
42
  /** One compiled generation of the project. Swapped wholesale on reload. */
38
43
  export type DevRuntime = {
@@ -310,7 +315,7 @@ async function serveLocalStorage(
310
315
  /**
311
316
  * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
312
317
  * and no static SPA serving (local Vite does that). Fastify stays `logger: false`;
313
- * project/auth/docs requests are recorded on the process-local inspect page after reply.
318
+ * project/auth/docs requests are recorded on the process-local dashboard after reply.
314
319
  * Routes, validation, and OpenAPI all come from `@robodev-ai/runtime`.
315
320
  */
316
321
  export async function startDevServer(
@@ -377,8 +382,18 @@ export async function startDevServer(
377
382
  const method = request.method.toLowerCase();
378
383
 
379
384
  if (isInspectPath(pathname)) {
380
- if (pathname === "/_robodev/logs.json") {
381
- return reply.send(options.inspect.list());
385
+ if (pathname === "/_robodev/logs") {
386
+ return reply.redirect("/_robodev");
387
+ }
388
+ if (pathname === "/_robodev/state.json" || pathname === "/_robodev/logs.json") {
389
+ return reply.send(
390
+ await buildDashboardState({
391
+ inspect: options.inspect,
392
+ runtime: getRuntime(),
393
+ googleClientId: options.googleClientId,
394
+ googleClientSecret: options.googleClientSecret,
395
+ }),
396
+ );
382
397
  }
383
398
  return reply.type("text/html; charset=utf-8").send(options.inspect.html());
384
399
  }
@@ -389,15 +404,20 @@ export async function startDevServer(
389
404
  } finally {
390
405
  const status = reply.statusCode;
391
406
  const durationMs = Date.now() - started;
392
- options.inspect.record({
393
- kind: "request",
394
- method: request.method,
395
- path: pathname,
396
- status,
397
- durationMs,
398
- });
399
- // Print here (not via `log`) so request lines stay off the Inspect Logs list.
400
- console.log(`${request.method} ${pathname} ${status} ${durationMs}ms`);
407
+ if (!isInspectPath(pathname)) {
408
+ options.inspect.record({
409
+ kind: "request",
410
+ method: request.method,
411
+ path: pathname,
412
+ status,
413
+ durationMs,
414
+ });
415
+ // Print here (not via `log`) so request lines stay off compile/reload logs.
416
+ console.log(`${request.method} ${pathname} ${status} ${durationMs}ms`);
417
+ if (shouldCountRequestHistogram(pathname, getRuntime(), request.method)) {
418
+ options.inspect.incrementRequestHistogram(status);
419
+ }
420
+ }
401
421
  }
402
422
  return reply;
403
423
 
package/src/dev.ts CHANGED
@@ -192,6 +192,16 @@ export async function main(args: string[]): Promise<void> {
192
192
  projectId: DEV_PROJECT_ID,
193
193
  jwtSecret: DEV_JWT_SECRET,
194
194
  }),
195
+ onJobFinished: (event) => {
196
+ inspect.record({
197
+ kind: "job_run",
198
+ name: event.name,
199
+ status: event.outcome,
200
+ payload: event.payload,
201
+ error: event.error ?? undefined,
202
+ });
203
+ inspect.incrementJobHistogram(event.outcome);
204
+ },
195
205
  });
196
206
 
197
207
  async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
@@ -323,7 +333,7 @@ export async function main(args: string[]): Promise<void> {
323
333
  console.log(`Storage ${relative(project.root, storageDir) || storageDir}`);
324
334
  console.log(`API ${apiUrl}`);
325
335
  console.log(`Swagger ${apiUrl}/_robodev/docs`);
326
- console.log(`Inspect ${apiUrl}/_robodev/logs`);
336
+ console.log(`Dashboard ${apiUrl}/_robodev`);
327
337
  console.log(
328
338
  `Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
329
339
  );
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.35.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.35.0",
14
+ "robodev": "^0.36.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.35.0",
14
+ "robodev": "^0.36.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.35.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.35.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }