robodev 0.34.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 +4 -2
- package/src/dev-clients.ts +31 -26
- package/src/dev-inspect.test.ts +136 -7
- package/src/dev-inspect.ts +685 -106
- package/src/dev-server.ts +301 -24
- package/src/dev.ts +43 -6
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/src/dev-inspect.ts
CHANGED
|
@@ -1,8 +1,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
|
|
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
|
|
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
|
-
/**
|
|
167
|
+
/** Dashboard HTML/JSON self-routes — do not print or record these as requests. */
|
|
67
168
|
export function isInspectPath(pathname: string): boolean {
|
|
68
|
-
return
|
|
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
|
-
|
|
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
|
|
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>
|
|
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:
|
|
105
|
-
padding:
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
.
|
|
128
|
-
.
|
|
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, .
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
220
|
-
render(
|
|
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>
|