@donkeylabs/server 2.0.7 → 2.0.11

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Admin Dashboard Module
3
+ *
4
+ * Built-in admin UI for monitoring jobs, processes, workflows,
5
+ * audit logs, SSE/WebSocket clients, and server stats.
6
+ *
7
+ * Security: Admin is dev-only by default - automatically enabled when
8
+ * NODE_ENV !== 'production' and disabled in production unless explicitly
9
+ * enabled with an `authorize` function.
10
+ */
11
+
12
+ import type { ServerContext } from "../router";
13
+ import { createAdminRouter } from "./routes";
14
+
15
+ export interface AdminConfig {
16
+ /**
17
+ * Enable admin dashboard.
18
+ * @default true in dev, false in production
19
+ */
20
+ enabled?: boolean;
21
+
22
+ /**
23
+ * Route prefix for admin routes.
24
+ * @default "admin"
25
+ */
26
+ prefix?: string;
27
+
28
+ /**
29
+ * Authorization function for admin access.
30
+ * Required for production use. Return true to allow access.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * authorize: (ctx) => {
35
+ * const user = ctx.plugins.auth.getUser(ctx);
36
+ * return user?.role === 'admin';
37
+ * }
38
+ * ```
39
+ */
40
+ authorize?: (ctx: ServerContext) => boolean;
41
+ }
42
+
43
+ /**
44
+ * Determine if admin should be enabled based on config and environment
45
+ */
46
+ export function isAdminEnabled(config?: AdminConfig): boolean {
47
+ const isDev = process.env.NODE_ENV !== "production";
48
+
49
+ // If explicitly set, use that
50
+ if (config?.enabled !== undefined) {
51
+ return config.enabled;
52
+ }
53
+
54
+ // Default: enabled in dev, disabled in production
55
+ return isDev;
56
+ }
57
+
58
+ /**
59
+ * Create the admin router with proper configuration
60
+ */
61
+ export function createAdmin(config?: AdminConfig) {
62
+ const prefix = config?.prefix ?? "admin";
63
+
64
+ if (!isAdminEnabled(config)) {
65
+ return null;
66
+ }
67
+
68
+ // Warn if enabled in production without authorize function
69
+ const isProduction = process.env.NODE_ENV === "production";
70
+ if (isProduction && !config?.authorize) {
71
+ console.warn(
72
+ "[Admin] WARNING: Admin dashboard enabled in production without authorization. " +
73
+ "This is a security risk. Add an 'authorize' function to your admin config."
74
+ );
75
+ }
76
+
77
+ return createAdminRouter({
78
+ prefix,
79
+ authorize: config?.authorize,
80
+ });
81
+ }
82
+
83
+ export { createAdminRouter } from "./routes";
84
+ export { adminStyles } from "./styles";
85
+ export * from "./dashboard";
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Admin Dashboard Routes
3
+ * All admin API endpoints
4
+ */
5
+
6
+ import { z } from "zod";
7
+ import type { ServerContext } from "../router";
8
+ import { createRouter, defineRoute } from "../router";
9
+ import {
10
+ renderDashboardLayout,
11
+ renderOverview,
12
+ renderJobsList,
13
+ renderProcessesList,
14
+ renderWorkflowsList,
15
+ renderAuditLogs,
16
+ renderSSEClients,
17
+ renderWebSocketClients,
18
+ renderEvents,
19
+ renderCache,
20
+ renderPlugins,
21
+ renderRoutes,
22
+ type DashboardData,
23
+ } from "./dashboard";
24
+ import type { AdminConfig } from "./index";
25
+
26
+ export interface AdminRouteContext {
27
+ prefix: string;
28
+ authorize?: AdminConfig["authorize"];
29
+ }
30
+
31
+ /**
32
+ * Create admin router with all dashboard routes
33
+ */
34
+ export function createAdminRouter(config: AdminRouteContext) {
35
+ const { prefix, authorize } = config;
36
+ const router = createRouter(prefix);
37
+
38
+ // Helper to check authorization
39
+ const checkAuth = (ctx: ServerContext): boolean => {
40
+ if (authorize) {
41
+ return authorize(ctx);
42
+ }
43
+ return true;
44
+ };
45
+
46
+ // Helper to get stats
47
+ const getStats = async (ctx: ServerContext): Promise<DashboardData["stats"]> => {
48
+ const { jobs, processes, workflows, sse, websocket } = ctx.core;
49
+
50
+ // Get job counts
51
+ const allJobs = await jobs.getAll({ limit: 1000 });
52
+ const jobCounts = {
53
+ pending: allJobs.filter((j) => j.status === "pending").length,
54
+ running: allJobs.filter((j) => j.status === "running").length,
55
+ completed: allJobs.filter((j) => j.status === "completed").length,
56
+ failed: allJobs.filter((j) => j.status === "failed").length,
57
+ };
58
+
59
+ // Get process counts
60
+ const runningProcesses = await processes.getRunning();
61
+ const processCounts = {
62
+ running: runningProcesses.length,
63
+ total: runningProcesses.length,
64
+ };
65
+
66
+ // Get workflow counts
67
+ const allWorkflows = await workflows.getAllInstances({ limit: 1000 });
68
+ const workflowCounts = {
69
+ running: allWorkflows.filter((w) => w.status === "running").length,
70
+ total: allWorkflows.length,
71
+ };
72
+
73
+ return {
74
+ uptime: process.uptime(),
75
+ memory: process.memoryUsage(),
76
+ jobs: jobCounts,
77
+ processes: processCounts,
78
+ workflows: workflowCounts,
79
+ sse: { clients: sse.getClients().length },
80
+ websocket: { clients: websocket.getClients().length },
81
+ };
82
+ };
83
+
84
+ // Main dashboard route (HTML)
85
+ router.route("dashboard").html({
86
+ input: z.object({
87
+ view: z.string().default("overview"),
88
+ // partial can be "1" or 1 (string or number from query params)
89
+ partial: z.union([z.string(), z.number()]).optional(),
90
+ status: z.string().optional(),
91
+ }),
92
+ handle: async (input, ctx) => {
93
+ if (!checkAuth(ctx)) {
94
+ return '<html><body><h1>Unauthorized</h1></body></html>';
95
+ }
96
+
97
+ const { view, partial, status } = input;
98
+ let content: string;
99
+
100
+ switch (view) {
101
+ case "overview": {
102
+ const stats = await getStats(ctx);
103
+ content = renderOverview(prefix, stats);
104
+ break;
105
+ }
106
+ case "jobs": {
107
+ const jobs = await ctx.core.jobs.getAll({
108
+ status: status as any,
109
+ limit: 100,
110
+ });
111
+ content = renderJobsList(prefix, jobs);
112
+ break;
113
+ }
114
+ case "processes": {
115
+ const processes = await ctx.core.processes.getRunning();
116
+ content = renderProcessesList(prefix, processes);
117
+ break;
118
+ }
119
+ case "workflows": {
120
+ const workflows = await ctx.core.workflows.getAllInstances({
121
+ status: status as any,
122
+ limit: 100,
123
+ });
124
+ content = renderWorkflowsList(prefix, workflows);
125
+ break;
126
+ }
127
+ case "audit": {
128
+ const logs = await ctx.core.audit.query({
129
+ limit: 100,
130
+ });
131
+ content = renderAuditLogs(prefix, logs);
132
+ break;
133
+ }
134
+ case "sse": {
135
+ const clients = ctx.core.sse.getClients();
136
+ content = renderSSEClients(prefix, clients);
137
+ break;
138
+ }
139
+ case "websocket": {
140
+ const clients = ctx.core.websocket.getClients();
141
+ content = renderWebSocketClients(prefix, clients);
142
+ break;
143
+ }
144
+ case "events": {
145
+ // Get recent events from event history if available
146
+ const events: any[] = [];
147
+ content = renderEvents(prefix, events);
148
+ break;
149
+ }
150
+ case "cache": {
151
+ const keys = await ctx.core.cache.keys?.() ?? [];
152
+ content = renderCache(prefix, keys);
153
+ break;
154
+ }
155
+ case "plugins": {
156
+ const plugins = Object.keys(ctx.plugins).map((name) => ({
157
+ name,
158
+ dependencies: [],
159
+ hasSchema: false,
160
+ }));
161
+ content = renderPlugins(prefix, plugins);
162
+ break;
163
+ }
164
+ case "routes": {
165
+ // Get routes from server context
166
+ const routes: any[] = [];
167
+ content = renderRoutes(prefix, routes);
168
+ break;
169
+ }
170
+ default:
171
+ content = "<div>Unknown view</div>";
172
+ }
173
+
174
+ // Return partial content for htmx requests
175
+ if (partial === "1" || partial === 1) {
176
+ return content;
177
+ }
178
+
179
+ return renderDashboardLayout(prefix, content, view);
180
+ },
181
+ });
182
+
183
+ // Stats API route
184
+ router.route("stats").typed(
185
+ defineRoute({
186
+ input: z.object({}),
187
+ output: z.object({
188
+ uptime: z.number(),
189
+ memory: z.object({
190
+ heapUsed: z.number(),
191
+ heapTotal: z.number(),
192
+ }),
193
+ jobs: z.object({
194
+ pending: z.number(),
195
+ running: z.number(),
196
+ completed: z.number(),
197
+ failed: z.number(),
198
+ }),
199
+ processes: z.object({
200
+ running: z.number(),
201
+ total: z.number(),
202
+ }),
203
+ workflows: z.object({
204
+ running: z.number(),
205
+ total: z.number(),
206
+ }),
207
+ sse: z.object({ clients: z.number() }),
208
+ websocket: z.object({ clients: z.number() }),
209
+ }),
210
+ handle: async (_input, ctx) => {
211
+ if (!checkAuth(ctx)) {
212
+ throw ctx.errors.Forbidden("Unauthorized");
213
+ }
214
+ return getStats(ctx);
215
+ },
216
+ })
217
+ );
218
+
219
+ // Jobs list route
220
+ router.route("jobs.list").typed(
221
+ defineRoute({
222
+ input: z.object({
223
+ status: z.enum(["pending", "running", "completed", "failed", "scheduled"]).optional(),
224
+ name: z.string().optional(),
225
+ limit: z.number().default(100),
226
+ offset: z.number().default(0),
227
+ }),
228
+ output: z.array(
229
+ z.object({
230
+ id: z.string(),
231
+ name: z.string(),
232
+ status: z.string(),
233
+ attempts: z.number(),
234
+ maxAttempts: z.number(),
235
+ createdAt: z.string(),
236
+ startedAt: z.string().nullable(),
237
+ completedAt: z.string().nullable(),
238
+ error: z.string().nullable(),
239
+ })
240
+ ),
241
+ handle: async (input, ctx) => {
242
+ if (!checkAuth(ctx)) {
243
+ throw ctx.errors.Forbidden("Unauthorized");
244
+ }
245
+ const jobs = await ctx.core.jobs.getAll(input);
246
+ return jobs.map((job) => ({
247
+ id: job.id,
248
+ name: job.name,
249
+ status: job.status,
250
+ attempts: job.attempts,
251
+ maxAttempts: job.maxAttempts,
252
+ createdAt: job.createdAt.toISOString(),
253
+ startedAt: job.startedAt?.toISOString() ?? null,
254
+ completedAt: job.completedAt?.toISOString() ?? null,
255
+ error: job.error ?? null,
256
+ }));
257
+ },
258
+ })
259
+ );
260
+
261
+ // Jobs cancel route
262
+ router.route("jobs.cancel").typed(
263
+ defineRoute({
264
+ input: z.object({ jobId: z.string() }),
265
+ output: z.object({ success: z.boolean() }),
266
+ handle: async (input, ctx) => {
267
+ if (!checkAuth(ctx)) {
268
+ throw ctx.errors.Forbidden("Unauthorized");
269
+ }
270
+ const success = await ctx.core.jobs.cancel(input.jobId);
271
+ return { success };
272
+ },
273
+ })
274
+ );
275
+
276
+ // Processes list route
277
+ router.route("processes.list").typed(
278
+ defineRoute({
279
+ input: z.object({}),
280
+ output: z.array(
281
+ z.object({
282
+ id: z.string(),
283
+ name: z.string(),
284
+ status: z.string(),
285
+ pid: z.number().nullable(),
286
+ restartCount: z.number(),
287
+ startedAt: z.string().nullable(),
288
+ })
289
+ ),
290
+ handle: async (_input, ctx) => {
291
+ if (!checkAuth(ctx)) {
292
+ throw ctx.errors.Forbidden("Unauthorized");
293
+ }
294
+ const processes = await ctx.core.processes.getRunning();
295
+ return processes.map((proc) => ({
296
+ id: proc.id,
297
+ name: proc.name,
298
+ status: proc.status,
299
+ pid: proc.pid ?? null,
300
+ restartCount: proc.restartCount ?? 0,
301
+ startedAt: proc.startedAt?.toISOString() ?? null,
302
+ }));
303
+ },
304
+ })
305
+ );
306
+
307
+ // Processes stop route
308
+ router.route("processes.stop").typed(
309
+ defineRoute({
310
+ input: z.object({ name: z.string() }),
311
+ output: z.object({ success: z.boolean() }),
312
+ handle: async (input, ctx) => {
313
+ if (!checkAuth(ctx)) {
314
+ throw ctx.errors.Forbidden("Unauthorized");
315
+ }
316
+ // Note: stop expects a process ID, not a name
317
+ // Get processes by name first
318
+ const processes = await ctx.core.processes.getByName(input.name);
319
+ for (const proc of processes) {
320
+ await ctx.core.processes.stop(proc.id);
321
+ }
322
+ return { success: true };
323
+ },
324
+ })
325
+ );
326
+
327
+ // Processes restart route
328
+ router.route("processes.restart").typed(
329
+ defineRoute({
330
+ input: z.object({ name: z.string() }),
331
+ output: z.object({ success: z.boolean() }),
332
+ handle: async (input, ctx) => {
333
+ if (!checkAuth(ctx)) {
334
+ throw ctx.errors.Forbidden("Unauthorized");
335
+ }
336
+ const processes = await ctx.core.processes.getByName(input.name);
337
+ for (const proc of processes) {
338
+ await ctx.core.processes.restart(proc.id);
339
+ }
340
+ return { success: true };
341
+ },
342
+ })
343
+ );
344
+
345
+ // Workflows list route
346
+ router.route("workflows.list").typed(
347
+ defineRoute({
348
+ input: z.object({
349
+ status: z.enum(["pending", "running", "completed", "failed", "cancelled", "timed_out"]).optional(),
350
+ workflowName: z.string().optional(),
351
+ limit: z.number().default(100),
352
+ offset: z.number().default(0),
353
+ }),
354
+ output: z.array(
355
+ z.object({
356
+ id: z.string(),
357
+ workflowName: z.string(),
358
+ status: z.string(),
359
+ currentStep: z.string().nullable(),
360
+ createdAt: z.string(),
361
+ startedAt: z.string().nullable(),
362
+ completedAt: z.string().nullable(),
363
+ error: z.string().nullable(),
364
+ })
365
+ ),
366
+ handle: async (input, ctx) => {
367
+ if (!checkAuth(ctx)) {
368
+ throw ctx.errors.Forbidden("Unauthorized");
369
+ }
370
+ const workflows = await ctx.core.workflows.getAllInstances(input);
371
+ return workflows.map((wf) => ({
372
+ id: wf.id,
373
+ workflowName: wf.workflowName,
374
+ status: wf.status,
375
+ currentStep: wf.currentStep ?? null,
376
+ createdAt: wf.createdAt.toISOString(),
377
+ startedAt: wf.startedAt?.toISOString() ?? null,
378
+ completedAt: wf.completedAt?.toISOString() ?? null,
379
+ error: wf.error ?? null,
380
+ }));
381
+ },
382
+ })
383
+ );
384
+
385
+ // Workflows cancel route
386
+ router.route("workflows.cancel").typed(
387
+ defineRoute({
388
+ input: z.object({ instanceId: z.string() }),
389
+ output: z.object({ success: z.boolean() }),
390
+ handle: async (input, ctx) => {
391
+ if (!checkAuth(ctx)) {
392
+ throw ctx.errors.Forbidden("Unauthorized");
393
+ }
394
+ const success = await ctx.core.workflows.cancel(input.instanceId);
395
+ return { success };
396
+ },
397
+ })
398
+ );
399
+
400
+ // Audit list route
401
+ router.route("audit.list").typed(
402
+ defineRoute({
403
+ input: z.object({
404
+ action: z.string().optional(),
405
+ actor: z.string().optional(),
406
+ resource: z.string().optional(),
407
+ limit: z.number().default(100),
408
+ offset: z.number().default(0),
409
+ }),
410
+ output: z.array(
411
+ z.object({
412
+ id: z.string(),
413
+ action: z.string(),
414
+ actor: z.string(),
415
+ resource: z.string(),
416
+ resourceId: z.string().nullable(),
417
+ timestamp: z.string(),
418
+ })
419
+ ),
420
+ handle: async (input, ctx) => {
421
+ if (!checkAuth(ctx)) {
422
+ throw ctx.errors.Forbidden("Unauthorized");
423
+ }
424
+ const logs = await ctx.core.audit.query({
425
+ action: input.action,
426
+ actor: input.actor,
427
+ resource: input.resource,
428
+ limit: input.limit,
429
+ offset: input.offset,
430
+ });
431
+ return logs.map((log) => ({
432
+ id: log.id,
433
+ action: log.action,
434
+ actor: log.actor,
435
+ resource: log.resource,
436
+ resourceId: log.resourceId ?? null,
437
+ timestamp: log.timestamp.toISOString(),
438
+ }));
439
+ },
440
+ })
441
+ );
442
+
443
+ // SSE clients route
444
+ router.route("sse.clients").typed(
445
+ defineRoute({
446
+ input: z.object({}),
447
+ output: z.array(
448
+ z.object({
449
+ id: z.string(),
450
+ channels: z.array(z.string()),
451
+ connectedAt: z.string(),
452
+ })
453
+ ),
454
+ handle: async (_input, ctx) => {
455
+ if (!checkAuth(ctx)) {
456
+ throw ctx.errors.Forbidden("Unauthorized");
457
+ }
458
+ const clients = ctx.core.sse.getClients();
459
+ return clients.map((client) => ({
460
+ id: client.id,
461
+ channels: Array.from(client.channels || []),
462
+ connectedAt: client.createdAt?.toISOString() ?? new Date().toISOString(),
463
+ }));
464
+ },
465
+ })
466
+ );
467
+
468
+ // WebSocket clients route
469
+ router.route("websocket.clients").typed(
470
+ defineRoute({
471
+ input: z.object({}),
472
+ output: z.array(
473
+ z.object({
474
+ id: z.string(),
475
+ connectedAt: z.string(),
476
+ })
477
+ ),
478
+ handle: async (_input, ctx) => {
479
+ if (!checkAuth(ctx)) {
480
+ throw ctx.errors.Forbidden("Unauthorized");
481
+ }
482
+ const clients = ctx.core.websocket.getClients();
483
+ return clients.map((client) => ({
484
+ id: client.id,
485
+ connectedAt: client.connectedAt?.toISOString() ?? new Date().toISOString(),
486
+ }));
487
+ },
488
+ })
489
+ );
490
+
491
+ // Cache keys route
492
+ router.route("cache.keys").typed(
493
+ defineRoute({
494
+ input: z.object({}),
495
+ output: z.array(z.string()),
496
+ handle: async (_input, ctx) => {
497
+ if (!checkAuth(ctx)) {
498
+ throw ctx.errors.Forbidden("Unauthorized");
499
+ }
500
+ return (await ctx.core.cache.keys?.()) ?? [];
501
+ },
502
+ })
503
+ );
504
+
505
+ // Plugins route
506
+ router.route("plugins").typed(
507
+ defineRoute({
508
+ input: z.object({}),
509
+ output: z.array(
510
+ z.object({
511
+ name: z.string(),
512
+ dependencies: z.array(z.string()),
513
+ })
514
+ ),
515
+ handle: async (_input, ctx) => {
516
+ if (!checkAuth(ctx)) {
517
+ throw ctx.errors.Forbidden("Unauthorized");
518
+ }
519
+ return Object.keys(ctx.plugins).map((name) => ({
520
+ name,
521
+ dependencies: [],
522
+ }));
523
+ },
524
+ })
525
+ );
526
+
527
+ // Routes list route
528
+ router.route("routes").typed(
529
+ defineRoute({
530
+ input: z.object({}),
531
+ output: z.array(
532
+ z.object({
533
+ name: z.string(),
534
+ handler: z.string(),
535
+ hasInput: z.boolean(),
536
+ hasOutput: z.boolean(),
537
+ })
538
+ ),
539
+ handle: async (_input, ctx) => {
540
+ if (!checkAuth(ctx)) {
541
+ throw ctx.errors.Forbidden("Unauthorized");
542
+ }
543
+ // This would need access to the server's route map
544
+ // For now, return empty array - will be populated when integrated
545
+ return [];
546
+ },
547
+ })
548
+ );
549
+
550
+ // Live stats SSE route
551
+ // Subscribes to the admin:stats channel for real-time updates
552
+ router.route("live").sse({
553
+ input: z.object({}),
554
+ events: {
555
+ stats: z.object({
556
+ uptime: z.number(),
557
+ memory: z.object({
558
+ heapUsed: z.number(),
559
+ heapTotal: z.number(),
560
+ }),
561
+ }),
562
+ },
563
+ handle: (_input, ctx) => {
564
+ if (!checkAuth(ctx)) {
565
+ return [];
566
+ }
567
+ // Return channel to subscribe to
568
+ return [`admin:stats`];
569
+ },
570
+ });
571
+
572
+ return router;
573
+ }