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 +2 -2
- package/src/dev-clients.ts +1 -0
- package/src/dev-inspect.test.ts +136 -7
- package/src/dev-inspect.ts +685 -106
- package/src/dev-server.ts +33 -13
- package/src/dev.ts +11 -1
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "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.
|
|
29
|
+
"@robodev-ai/runtime": "0.4.1",
|
|
30
30
|
"@robodev-ai/sdk": "0.11.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
package/src/dev-clients.ts
CHANGED
|
@@ -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,
|
package/src/dev-inspect.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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,
|
|
118
|
-
assert.match(html,
|
|
119
|
-
assert.match(html,
|
|
120
|
-
assert.match(html,
|
|
121
|
-
assert.match(html,
|
|
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
|
});
|
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>
|
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 {
|
|
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
|
|
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
|
|
381
|
-
return reply.
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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(`
|
|
336
|
+
console.log(`Dashboard ${apiUrl}/_robodev`);
|
|
327
337
|
console.log(
|
|
328
338
|
`Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
|
|
329
339
|
);
|