@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.
- package/docs/lifecycle-hooks.md +16 -16
- package/docs/processes.md +93 -0
- package/package.json +13 -3
- package/src/admin/dashboard.ts +717 -0
- package/src/admin/index.ts +85 -0
- package/src/admin/routes.ts +573 -0
- package/src/admin/styles.ts +422 -0
- package/src/core/index.ts +25 -0
- package/src/core/job-adapter-kysely.ts +22 -1
- package/src/core/job-adapter-sqlite.ts +22 -1
- package/src/core/jobs.ts +37 -0
- package/src/core/process-client.ts +121 -0
- package/src/core/processes.ts +67 -0
- package/src/core/storage-adapter-local.ts +403 -0
- package/src/core/storage-adapter-s3.ts +409 -0
- package/src/core/storage.ts +543 -0
- package/src/core/websocket.ts +13 -3
- package/src/core/workflow-adapter-kysely.ts +22 -1
- package/src/core/workflows.ts +37 -0
- package/src/core.ts +10 -1
- package/src/harness.ts +3 -0
- package/src/index.ts +19 -0
- package/src/process-client.ts +7 -0
- package/src/server.ts +71 -31
|
@@ -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
|
+
}
|