tina4-nodejs 3.0.0-rc.2

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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,2024 @@
1
+ /**
2
+ * Tina4 Dev Admin — Built-in development dashboard, zero dependencies.
3
+ *
4
+ * Auto-registered admin panel for development mode.
5
+ * Provides API endpoints and a single-page UI at /__dev/ for:
6
+ * - Route inspector (all registered routes, methods)
7
+ * - Message log (tracked debug messages)
8
+ * - Request inspector (captured HTTP requests)
9
+ * - System info (Node.js version, V8, memory, uptime, platform)
10
+ */
11
+
12
+ import { cpus as osCpus } from "node:os";
13
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from "node:fs";
14
+ import { join, dirname, resolve, relative } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import type { Router } from "./router.js";
17
+ import type { RouteHandler } from "./types.js";
18
+ import { DevMailbox } from "./devMailbox.js";
19
+ import { isTruthy } from "./dotenv.js";
20
+
21
+ const cpuCount = osCpus().length;
22
+
23
+ const TINA4_VERSION = "3.0.0";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface LogEntry {
30
+ id: string;
31
+ timestamp: string;
32
+ category: string;
33
+ level: string;
34
+ message: string;
35
+ data?: unknown;
36
+ }
37
+
38
+ interface RequestEntry {
39
+ id: string;
40
+ timestamp: string;
41
+ method: string;
42
+ path: string;
43
+ status: number;
44
+ durationMs: number;
45
+ }
46
+
47
+ interface RequestStats {
48
+ total: number;
49
+ avgMs: number;
50
+ errors: number;
51
+ slowestMs: number;
52
+ }
53
+
54
+ interface ErrorEntry {
55
+ id: string;
56
+ timestamp: string;
57
+ message: string;
58
+ stack?: string;
59
+ resolved: boolean;
60
+ }
61
+
62
+ interface QueueJob {
63
+ id: string;
64
+ timestamp: string;
65
+ name: string;
66
+ status: "pending" | "completed" | "failed" | "reserved";
67
+ payload?: unknown;
68
+ result?: unknown;
69
+ error?: string;
70
+ }
71
+
72
+ interface WsConnection {
73
+ id: string;
74
+ connectedAt: string;
75
+ remoteAddress: string;
76
+ path: string;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // MessageLog — In-memory message log for dev mode tracking
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export class MessageLog {
84
+ private static messages: LogEntry[] = [];
85
+ private static maxMessages = 500;
86
+
87
+ static log(category: string, level: string, message: string, data?: unknown): void {
88
+ const entry: LogEntry = {
89
+ id: `${Date.now()}_${this.messages.length}`,
90
+ timestamp: new Date().toISOString(),
91
+ category,
92
+ level,
93
+ message,
94
+ data,
95
+ };
96
+ this.messages.push(entry);
97
+ if (this.messages.length > this.maxMessages) {
98
+ this.messages = this.messages.slice(-this.maxMessages);
99
+ }
100
+ }
101
+
102
+ static get(category?: string, limit = 100): LogEntry[] {
103
+ let msgs = this.messages;
104
+ if (category) {
105
+ msgs = msgs.filter((m) => m.category === category);
106
+ }
107
+ return msgs.slice().reverse().slice(0, limit);
108
+ }
109
+
110
+ static clear(category?: string): void {
111
+ if (category) {
112
+ this.messages = this.messages.filter((m) => m.category !== category);
113
+ } else {
114
+ this.messages = [];
115
+ }
116
+ }
117
+
118
+ static count(): Record<string, number> {
119
+ const counts: Record<string, number> = { total: this.messages.length };
120
+ for (const m of this.messages) {
121
+ counts[m.category] = (counts[m.category] ?? 0) + 1;
122
+ }
123
+ return counts;
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // RequestInspector — Captures recent HTTP requests
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export class RequestInspector {
132
+ private static requests: RequestEntry[] = [];
133
+ private static maxRequests = 200;
134
+
135
+ static capture(method: string, path: string, status: number, duration: number): void {
136
+ const entry: RequestEntry = {
137
+ id: `${Date.now()}_${this.requests.length}`,
138
+ timestamp: new Date().toISOString(),
139
+ method,
140
+ path,
141
+ status,
142
+ durationMs: Math.round(duration * 100) / 100,
143
+ };
144
+ this.requests.push(entry);
145
+ if (this.requests.length > this.maxRequests) {
146
+ this.requests = this.requests.slice(-this.maxRequests);
147
+ }
148
+ }
149
+
150
+ static get(limit = 50): RequestEntry[] {
151
+ return this.requests.slice().reverse().slice(0, limit);
152
+ }
153
+
154
+ static stats(): RequestStats {
155
+ if (this.requests.length === 0) {
156
+ return { total: 0, avgMs: 0, errors: 0, slowestMs: 0 };
157
+ }
158
+ const durations = this.requests.map((r) => r.durationMs);
159
+ const errors = this.requests.filter((r) => r.status >= 400).length;
160
+ return {
161
+ total: this.requests.length,
162
+ avgMs: Math.round((durations.reduce((a, b) => a + b, 0) / durations.length) * 100) / 100,
163
+ errors,
164
+ slowestMs: Math.round(Math.max(...durations) * 100) / 100,
165
+ };
166
+ }
167
+
168
+ static clear(): void {
169
+ this.requests = [];
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // ErrorTracker — In-memory tracked errors for dev mode
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export class ErrorTracker {
178
+ private static errors: ErrorEntry[] = [];
179
+
180
+ static track(message: string, stack?: string): void {
181
+ this.errors.push({
182
+ id: `err_${Date.now()}_${this.errors.length}`,
183
+ timestamp: new Date().toISOString(),
184
+ message,
185
+ stack,
186
+ resolved: false,
187
+ });
188
+ }
189
+
190
+ static get(): ErrorEntry[] {
191
+ return this.errors.slice().reverse();
192
+ }
193
+
194
+ static resolve(id: string): boolean {
195
+ const entry = this.errors.find((e) => e.id === id);
196
+ if (entry) {
197
+ entry.resolved = true;
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ static clearResolved(): void {
204
+ this.errors = this.errors.filter((e) => !e.resolved);
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // DevMailboxStore — File-backed dev mailbox (delegates to DevMailbox)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export class DevMailboxStore {
213
+ private static mailbox = new DevMailbox();
214
+
215
+ static inbox(folder: string = "inbox", limit: number = 50, offset: number = 0) {
216
+ return this.mailbox.inbox(limit, offset, folder);
217
+ }
218
+
219
+ static read(id: string) {
220
+ return this.mailbox.read(id);
221
+ }
222
+
223
+ static seed(count = 5): void {
224
+ this.mailbox.seed(count);
225
+ }
226
+
227
+ static clear(folder?: string): void {
228
+ this.mailbox.clear(folder);
229
+ }
230
+
231
+ static unreadCount(): number {
232
+ return this.mailbox.unreadCount();
233
+ }
234
+
235
+ static count(folder?: string): { inbox: number; outbox: number; total: number } {
236
+ return this.mailbox.count(folder);
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // DevQueue — In-memory dev queue
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export class DevQueue {
245
+ private static jobs: QueueJob[] = [];
246
+
247
+ static stats(): { pending: number; completed: number; failed: number; reserved: number; jobs: QueueJob[] } {
248
+ const pending = this.jobs.filter((j) => j.status === "pending").length;
249
+ const completed = this.jobs.filter((j) => j.status === "completed").length;
250
+ const failed = this.jobs.filter((j) => j.status === "failed").length;
251
+ const reserved = this.jobs.filter((j) => j.status === "reserved").length;
252
+ return { pending, completed, failed, reserved, jobs: this.jobs.slice().reverse() };
253
+ }
254
+
255
+ static add(name: string, payload?: unknown): QueueJob {
256
+ const job: QueueJob = {
257
+ id: `job_${Date.now()}_${this.jobs.length}`,
258
+ timestamp: new Date().toISOString(),
259
+ name,
260
+ status: "pending",
261
+ payload,
262
+ };
263
+ this.jobs.push(job);
264
+ return job;
265
+ }
266
+
267
+ static retryFailed(): number {
268
+ let count = 0;
269
+ for (const job of this.jobs) {
270
+ if (job.status === "failed") {
271
+ job.status = "pending";
272
+ job.error = undefined;
273
+ count++;
274
+ }
275
+ }
276
+ return count;
277
+ }
278
+
279
+ static purgeCompleted(): number {
280
+ const before = this.jobs.length;
281
+ this.jobs = this.jobs.filter((j) => j.status !== "completed");
282
+ return before - this.jobs.length;
283
+ }
284
+
285
+ static replay(id: string): QueueJob | undefined {
286
+ const job = this.jobs.find((j) => j.id === id);
287
+ if (job) {
288
+ const newJob = this.add(job.name, job.payload);
289
+ return newJob;
290
+ }
291
+ return undefined;
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // WsTracker — In-memory WebSocket connection tracker
297
+ // ---------------------------------------------------------------------------
298
+
299
+ export class WsTracker {
300
+ private static connections: WsConnection[] = [];
301
+
302
+ static add(remoteAddress: string, path: string): string {
303
+ const conn: WsConnection = {
304
+ id: `ws_${Date.now()}_${this.connections.length}`,
305
+ connectedAt: new Date().toISOString(),
306
+ remoteAddress,
307
+ path,
308
+ };
309
+ this.connections.push(conn);
310
+ return conn.id;
311
+ }
312
+
313
+ static remove(id: string): boolean {
314
+ const idx = this.connections.findIndex((c) => c.id === id);
315
+ if (idx >= 0) {
316
+ this.connections.splice(idx, 1);
317
+ return true;
318
+ }
319
+ return false;
320
+ }
321
+
322
+ static list(): WsConnection[] {
323
+ return this.connections.slice();
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // DevAdmin — Registers /__dev routes on the router
329
+ // ---------------------------------------------------------------------------
330
+
331
+ export class DevAdmin {
332
+ /**
333
+ * Check whether dev mode is enabled.
334
+ */
335
+ static isEnabled(): boolean {
336
+ return isTruthy(process.env.TINA4_DEBUG);
337
+ }
338
+
339
+ /**
340
+ * Register all /__dev routes on the given router.
341
+ */
342
+ static register(router: Router): void {
343
+ const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
344
+ // Dashboard
345
+ { method: "GET", pattern: "/__dev", handler: handleDashboard },
346
+ // Status & system
347
+ { method: "GET", pattern: "/__dev/api/status", handler: handleStatus(router) },
348
+ { method: "GET", pattern: "/__dev/api/system", handler: handleSystem },
349
+ // Routes
350
+ { method: "GET", pattern: "/__dev/api/routes", handler: handleRoutes(router) },
351
+ // Messages
352
+ { method: "GET", pattern: "/__dev/api/messages", handler: handleMessages },
353
+ { method: "POST", pattern: "/__dev/api/messages/clear", handler: handleMessagesClear },
354
+ { method: "GET", pattern: "/__dev/api/messages/search", handler: handleMessagesSearch },
355
+ // Requests
356
+ { method: "GET", pattern: "/__dev/api/requests", handler: handleRequests },
357
+ { method: "POST", pattern: "/__dev/api/requests/clear", handler: handleRequestsClear },
358
+ // Queue management
359
+ { method: "GET", pattern: "/__dev/api/queue", handler: handleQueue },
360
+ { method: "POST", pattern: "/__dev/api/queue/retry", handler: handleQueueRetry },
361
+ { method: "POST", pattern: "/__dev/api/queue/purge", handler: handleQueuePurge },
362
+ { method: "POST", pattern: "/__dev/api/queue/replay", handler: handleQueueReplay },
363
+ // Mailbox
364
+ { method: "GET", pattern: "/__dev/api/mailbox", handler: handleMailbox },
365
+ { method: "GET", pattern: "/__dev/api/mailbox/read", handler: handleMailboxRead },
366
+ { method: "POST", pattern: "/__dev/api/mailbox/seed", handler: handleMailboxSeed },
367
+ { method: "POST", pattern: "/__dev/api/mailbox/clear", handler: handleMailboxClear },
368
+ // Database
369
+ { method: "GET", pattern: "/__dev/api/table", handler: handleTable },
370
+ { method: "GET", pattern: "/__dev/api/tables", handler: handleTables },
371
+ { method: "POST", pattern: "/__dev/api/seed", handler: handleSeed },
372
+ { method: "POST", pattern: "/__dev/api/query", handler: handleQuery },
373
+ // Errors / Broken
374
+ { method: "GET", pattern: "/__dev/api/broken", handler: handleBroken },
375
+ { method: "POST", pattern: "/__dev/api/broken/resolve", handler: handleBrokenResolve },
376
+ { method: "POST", pattern: "/__dev/api/broken/clear", handler: handleBrokenClear },
377
+ // WebSockets
378
+ { method: "GET", pattern: "/__dev/api/websockets", handler: handleWebsockets },
379
+ { method: "POST", pattern: "/__dev/api/websockets/disconnect", handler: handleWebsocketsDisconnect },
380
+ // Tools
381
+ { method: "POST", pattern: "/__dev/api/tool", handler: handleTool },
382
+ // Chat
383
+ { method: "POST", pattern: "/__dev/api/chat", handler: handleChat },
384
+ // Connections
385
+ { method: "GET", pattern: "/__dev/api/connections", handler: handleConnections },
386
+ { method: "POST", pattern: "/__dev/api/connections/test", handler: handleConnectionsTest },
387
+ { method: "POST", pattern: "/__dev/api/connections/save", handler: handleConnectionsSave },
388
+ // Gallery
389
+ { method: "GET", pattern: "/__dev/api/gallery", handler: handleGalleryList },
390
+ { method: "POST", pattern: "/__dev/api/gallery/deploy", handler: handleGalleryDeploy(router) },
391
+ // JS asset
392
+ { method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs },
393
+ ];
394
+
395
+ for (const route of routes) {
396
+ router.addRoute({
397
+ method: route.method,
398
+ pattern: route.pattern,
399
+ handler: route.handler,
400
+ });
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Returns the dev toolbar HTML to inject into HTML pages.
406
+ */
407
+ static renderToolbarHtml(ctx: {
408
+ version: string;
409
+ method: string;
410
+ path: string;
411
+ matchedPattern: string;
412
+ requestId: string;
413
+ routeCount: number;
414
+ }): string {
415
+ return renderToolbarHtml(ctx);
416
+ }
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Route handlers
421
+ // ---------------------------------------------------------------------------
422
+
423
+ const handleDashboard: RouteHandler = (_req, res) => {
424
+ res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
425
+ res.raw.end(renderDashboard());
426
+ };
427
+
428
+ function handleStatus(router: Router): RouteHandler {
429
+ return (_req, res) => {
430
+ const mem = process.memoryUsage();
431
+ const reqStats = RequestInspector.stats();
432
+ const msgCounts = MessageLog.count();
433
+ const errors = ErrorTracker.get();
434
+ const unresolved = errors.filter((e) => !e.resolved).length;
435
+ const mailboxCounts = DevMailboxStore.count();
436
+ res.json({
437
+ nodeVersion: process.version,
438
+ framework: `tina4-nodejs v${TINA4_VERSION}`,
439
+ debug: process.env.TINA4_DEBUG ?? "false",
440
+ logLevel: process.env.TINA4_LOG_LEVEL ?? "ERROR",
441
+ routes: router.getRoutes().length,
442
+ messages: msgCounts,
443
+ message_counts: msgCounts,
444
+ requests: reqStats,
445
+ request_stats: { total: reqStats.total, avg_ms: reqStats.avgMs, errors: reqStats.errors, slowest_ms: reqStats.slowestMs },
446
+ health: { unresolved },
447
+ mailbox: { total: mailboxCounts.total, unread: DevMailboxStore.unreadCount() },
448
+ memory: {
449
+ rss: Math.round(mem.rss / 1048576),
450
+ heapUsed: Math.round(mem.heapUsed / 1048576),
451
+ heapTotal: Math.round(mem.heapTotal / 1048576),
452
+ },
453
+ uptime: Math.round(process.uptime()),
454
+ timestamp: new Date().toISOString(),
455
+ });
456
+ };
457
+ }
458
+
459
+ function handleRoutes(router: Router): RouteHandler {
460
+ const internalPrefixes = ["/__dev", "/health", "/swagger"];
461
+ return (_req, res) => {
462
+ const allRoutes = router.getRoutes();
463
+ const result = allRoutes
464
+ .filter((r) => !internalPrefixes.some((prefix) => r.pattern.startsWith(prefix)))
465
+ .map((r) => ({
466
+ method: r.method,
467
+ path: r.pattern,
468
+ filePath: r.filePath ?? null,
469
+ hasMiddleware: (r.middlewares?.length ?? 0) > 0,
470
+ meta: r.meta ?? null,
471
+ }));
472
+ res.json({ routes: result, count: result.length });
473
+ };
474
+ }
475
+
476
+ const handleMessages: RouteHandler = (req, res) => {
477
+ const url = new URL(req.url ?? "/", "http://localhost");
478
+ const category = url.searchParams.get("category") ?? undefined;
479
+ const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
480
+ res.json({
481
+ messages: MessageLog.get(category, limit),
482
+ counts: MessageLog.count(),
483
+ });
484
+ };
485
+
486
+ const handleMessagesClear: RouteHandler = (req, res) => {
487
+ const url = new URL(req.url ?? "/", "http://localhost");
488
+ const category = url.searchParams.get("category") ?? undefined;
489
+ MessageLog.clear(category);
490
+ res.json({ cleared: true });
491
+ };
492
+
493
+ const handleRequests: RouteHandler = (req, res) => {
494
+ const url = new URL(req.url ?? "/", "http://localhost");
495
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
496
+ const rawRequests = RequestInspector.get(limit);
497
+ const rawStats = RequestInspector.stats();
498
+ // Map to shared JS format: duration_ms, body_size, avg_ms, errors, slowest_ms
499
+ const mappedRequests = rawRequests.map((r) => ({
500
+ timestamp: r.timestamp,
501
+ method: r.method,
502
+ path: r.path,
503
+ status: r.status,
504
+ duration_ms: r.durationMs,
505
+ body_size: 0,
506
+ }));
507
+ res.json({
508
+ requests: mappedRequests,
509
+ stats: {
510
+ total: rawStats.total,
511
+ avg_ms: rawStats.avgMs,
512
+ errors: rawStats.errors,
513
+ slowest_ms: rawStats.slowestMs,
514
+ },
515
+ });
516
+ };
517
+
518
+ const handleRequestsClear: RouteHandler = (_req, res) => {
519
+ RequestInspector.clear();
520
+ res.json({ cleared: true });
521
+ };
522
+
523
+ const handleSystem: RouteHandler = (_req, res) => {
524
+ const mem = process.memoryUsage();
525
+ const heapUsedMb = Math.round(mem.heapUsed / 1048576);
526
+ const rssMb = Math.round(mem.rss / 1048576);
527
+ // Respond in both the shared-JS format and the Node-specific format
528
+ res.json({
529
+ // Shared JS fields
530
+ node_version: process.version,
531
+ platform: process.platform,
532
+ architecture: process.arch,
533
+ os: `${process.platform} ${process.arch}`,
534
+ pid: process.pid,
535
+ memory_mb: heapUsedMb,
536
+ memory: {
537
+ current_mb: heapUsedMb,
538
+ peak_mb: rssMb,
539
+ limit: "V8 default",
540
+ rss: `${rssMb} MB`,
541
+ heapUsed: `${heapUsedMb} MB`,
542
+ heapTotal: `${Math.round(mem.heapTotal / 1048576)} MB`,
543
+ external: `${Math.round(mem.external / 1048576)} MB`,
544
+ },
545
+ framework: {
546
+ name: "tina4-nodejs",
547
+ version: TINA4_VERSION,
548
+ route_count: "",
549
+ },
550
+ debug: process.env.TINA4_DEBUG ?? "false",
551
+ log_level: process.env.TINA4_LOG_LEVEL ?? "ERROR",
552
+ // Node-specific extras
553
+ node: {
554
+ version: process.version,
555
+ v8: process.versions.v8,
556
+ platform: process.platform,
557
+ arch: process.arch,
558
+ pid: process.pid,
559
+ },
560
+ uptime: {
561
+ seconds: Math.round(process.uptime()),
562
+ formatted: formatUptime(process.uptime()),
563
+ },
564
+ env: {
565
+ TINA4_DEBUG: process.env.TINA4_DEBUG ?? "false",
566
+ TINA4_LOG_LEVEL: process.env.TINA4_LOG_LEVEL ?? "ERROR",
567
+ },
568
+ cpus: cpuCount,
569
+ });
570
+ };
571
+
572
+ // -- Messages search --
573
+
574
+ const handleMessagesSearch: RouteHandler = (req, res) => {
575
+ const url = new URL(req.url ?? "/", "http://localhost");
576
+ const q = (url.searchParams.get("q") ?? "").toLowerCase();
577
+ if (!q) {
578
+ res.json({ messages: [], query: "" });
579
+ return;
580
+ }
581
+ const all = MessageLog.get(undefined, 500);
582
+ const results = all.filter(
583
+ (m) => m.message.toLowerCase().includes(q) || m.category.toLowerCase().includes(q),
584
+ );
585
+ res.json({ messages: results, query: q, count: results.length });
586
+ };
587
+
588
+ // -- Queue handlers --
589
+
590
+ const handleQueue: RouteHandler = (req, res) => {
591
+ const url = new URL(req.url ?? "/", "http://localhost");
592
+ const statusFilter = url.searchParams.get("status") ?? "";
593
+ const data = DevQueue.stats();
594
+ let jobs = data.jobs;
595
+ if (statusFilter) {
596
+ jobs = jobs.filter((j) => j.status === statusFilter);
597
+ }
598
+ // Map to shared JS format: topic, attempts, created_at, data
599
+ const mappedJobs = jobs.map((j) => ({
600
+ id: j.id,
601
+ topic: j.name,
602
+ status: j.status,
603
+ attempts: 1,
604
+ created_at: j.timestamp,
605
+ data: j.payload ?? {},
606
+ }));
607
+ res.json({
608
+ stats: { pending: data.pending, completed: data.completed, failed: data.failed, reserved: data.reserved },
609
+ jobs: mappedJobs,
610
+ });
611
+ };
612
+
613
+ const handleQueueRetry: RouteHandler = (_req, res) => {
614
+ const count = DevQueue.retryFailed();
615
+ res.json({ retried: count });
616
+ };
617
+
618
+ const handleQueuePurge: RouteHandler = (_req, res) => {
619
+ const count = DevQueue.purgeCompleted();
620
+ res.json({ purged: count });
621
+ };
622
+
623
+ const handleQueueReplay: RouteHandler = (req, res) => {
624
+ const url = new URL(req.url ?? "/", "http://localhost");
625
+ const id = url.searchParams.get("id") ?? "";
626
+ if (!id) {
627
+ // Try reading from body
628
+ const bodyId = (req as any).body?.id ?? "";
629
+ if (!bodyId) {
630
+ res.json({ error: "Missing job id" });
631
+ return;
632
+ }
633
+ const job = DevQueue.replay(bodyId);
634
+ res.json(job ? { replayed: true, job } : { error: "Job not found" });
635
+ return;
636
+ }
637
+ const job = DevQueue.replay(id);
638
+ res.json(job ? { replayed: true, job } : { error: "Job not found" });
639
+ };
640
+
641
+ // -- Mailbox handlers --
642
+
643
+ const handleMailbox: RouteHandler = (req, res) => {
644
+ const url = new URL(req.url ?? "/", "http://localhost");
645
+ const folder = url.searchParams.get("folder") ?? "inbox";
646
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
647
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
648
+ const messages = DevMailboxStore.inbox(folder, limit, offset);
649
+ const counts = DevMailboxStore.count();
650
+ const unread = DevMailboxStore.unreadCount();
651
+ res.json({ messages, counts, unread });
652
+ };
653
+
654
+ const handleMailboxRead: RouteHandler = (req, res) => {
655
+ const url = new URL(req.url ?? "/", "http://localhost");
656
+ const id = url.searchParams.get("id") ?? "";
657
+ const msg = DevMailboxStore.read(id);
658
+ // Shared JS expects the message fields at top level (not wrapped in .message)
659
+ res.json(msg ? msg : { error: "Message not found" });
660
+ };
661
+
662
+ const handleMailboxSeed: RouteHandler = (req, res) => {
663
+ const url = new URL(req.url ?? "/", "http://localhost");
664
+ const count = parseInt(url.searchParams.get("count") ?? "5", 10);
665
+ DevMailboxStore.seed(count);
666
+ res.json({ seeded: count });
667
+ };
668
+
669
+ const handleMailboxClear: RouteHandler = (req, res) => {
670
+ const url = new URL(req.url ?? "/", "http://localhost");
671
+ const folder = url.searchParams.get("folder") ?? undefined;
672
+ DevMailboxStore.clear(folder);
673
+ res.json({ cleared: true, folder: folder ?? "all" });
674
+ };
675
+
676
+ // -- Database handlers (stubs) --
677
+
678
+ const handleTable: RouteHandler = (req, res) => {
679
+ const url = new URL(req.url ?? "/", "http://localhost");
680
+ const name = url.searchParams.get("name") ?? "";
681
+ if (!name) {
682
+ res.json({ error: "Missing table name parameter" });
683
+ return;
684
+ }
685
+ // Stub response — actual implementation will use ORM adapter
686
+ res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
687
+ };
688
+
689
+ const handleTables: RouteHandler = (_req, res) => {
690
+ // Stub response — actual implementation will use ORM adapter
691
+ res.json({ tables: [], message: "Database not connected" });
692
+ };
693
+
694
+ const handleSeed: RouteHandler = (req, res) => {
695
+ const url = new URL(req.url ?? "/", "http://localhost");
696
+ const table = url.searchParams.get("table") ?? (req as any).body?.table ?? "";
697
+ if (!table) {
698
+ res.json({ error: "Missing table parameter" });
699
+ return;
700
+ }
701
+ // Stub response — actual implementation will use ORM adapter
702
+ res.json({ seeded: false, table, message: "Database not connected" });
703
+ };
704
+
705
+ const handleQuery: RouteHandler = (req, res) => {
706
+ const query = (req as any).body?.query ?? "";
707
+ if (!query) {
708
+ res.json({ error: "Missing query parameter" });
709
+ return;
710
+ }
711
+ // Stub response — actual implementation will use ORM adapter
712
+ res.json({ query, rows: [], message: "Database not connected" });
713
+ };
714
+
715
+ // -- Broken (errors) handlers --
716
+
717
+ const handleBroken: RouteHandler = (_req, res) => {
718
+ const errors = ErrorTracker.get();
719
+ const unresolved = errors.filter((e) => !e.resolved).length;
720
+ // Map to shared JS format: error_type, message, traceback, count, last_seen
721
+ const mappedErrors = errors.map((e) => ({
722
+ id: e.id,
723
+ error_type: "Error",
724
+ message: e.message,
725
+ traceback: e.stack ?? "",
726
+ count: 1,
727
+ last_seen: e.timestamp,
728
+ resolved: e.resolved,
729
+ }));
730
+ res.json({ errors: mappedErrors, health: { unresolved }, count: errors.length });
731
+ };
732
+
733
+ const handleBrokenResolve: RouteHandler = (req, res) => {
734
+ const url = new URL(req.url ?? "/", "http://localhost");
735
+ const id = url.searchParams.get("id") ?? (req as any).body?.id ?? "";
736
+ if (!id) {
737
+ res.json({ error: "Missing error id" });
738
+ return;
739
+ }
740
+ const resolved = ErrorTracker.resolve(id);
741
+ res.json({ resolved });
742
+ };
743
+
744
+ const handleBrokenClear: RouteHandler = (_req, res) => {
745
+ ErrorTracker.clearResolved();
746
+ res.json({ cleared: true });
747
+ };
748
+
749
+ // -- WebSocket handlers --
750
+
751
+ const handleWebsockets: RouteHandler = (_req, res) => {
752
+ const conns = WsTracker.list();
753
+ // Map to shared JS format: ip, connected_at, closed
754
+ const mapped = conns.map((c) => ({
755
+ id: c.id,
756
+ path: c.path,
757
+ ip: c.remoteAddress,
758
+ connected_at: c.connectedAt,
759
+ closed: false,
760
+ }));
761
+ res.json({ connections: mapped, count: mapped.length });
762
+ };
763
+
764
+ const handleWebsocketsDisconnect: RouteHandler = (req, res) => {
765
+ const url = new URL(req.url ?? "/", "http://localhost");
766
+ const id = url.searchParams.get("id") ?? (req as any).body?.id ?? "";
767
+ if (!id) {
768
+ res.json({ error: "Missing connection id" });
769
+ return;
770
+ }
771
+ const removed = WsTracker.remove(id);
772
+ res.json({ disconnected: removed });
773
+ };
774
+
775
+ // -- Tool handler --
776
+
777
+ const handleTool: RouteHandler = (req, res) => {
778
+ const tool = (req as any).body?.tool ?? "";
779
+ const validTools = ["test", "migrate", "seed", "routes", "carbon", "ai"];
780
+ if (!tool || !validTools.includes(tool)) {
781
+ res.json({ error: `Invalid tool. Valid tools: ${validTools.join(", ")}` });
782
+ return;
783
+ }
784
+ // Stub response — actual implementations will be wired in later
785
+ res.json({ tool, status: "executed", message: `Tool '${tool}' executed (stub)`, timestamp: new Date().toISOString() });
786
+ };
787
+
788
+ // -- Chat handler --
789
+
790
+ const handleChat: RouteHandler = (req, res) => {
791
+ const message = (req as any).body?.message ?? "";
792
+ if (!message) {
793
+ res.json({ error: "Missing message parameter" });
794
+ return;
795
+ }
796
+ // Placeholder AI chat response
797
+ res.json({
798
+ reply: `AI chat is not yet configured. You said: "${message}"`,
799
+ timestamp: new Date().toISOString(),
800
+ });
801
+ };
802
+
803
+ // ---------------------------------------------------------------------------
804
+
805
+ function formatUptime(seconds: number): string {
806
+ const d = Math.floor(seconds / 86400);
807
+ const h = Math.floor((seconds % 86400) / 3600);
808
+ const m = Math.floor((seconds % 3600) / 60);
809
+ const s = Math.floor(seconds % 60);
810
+ const parts: string[] = [];
811
+ if (d > 0) parts.push(`${d}d`);
812
+ if (h > 0) parts.push(`${h}h`);
813
+ if (m > 0) parts.push(`${m}m`);
814
+ parts.push(`${s}s`);
815
+ return parts.join(" ");
816
+ }
817
+
818
+ // ---------------------------------------------------------------------------
819
+ // Connection helpers
820
+ // ---------------------------------------------------------------------------
821
+
822
+ function parseEnvFile(): Record<string, string> {
823
+ const envPath = join(process.cwd(), ".env");
824
+ const result: Record<string, string> = {};
825
+ if (!existsSync(envPath)) return result;
826
+ const lines = readFileSync(envPath, "utf-8").split("\n");
827
+ for (const line of lines) {
828
+ const trimmed = line.trim();
829
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
830
+ const [key, ...rest] = trimmed.split("=");
831
+ result[key.trim()] = rest.join("=").trim().replace(/^["']|["']$/g, "");
832
+ }
833
+ return result;
834
+ }
835
+
836
+ const handleConnections: RouteHandler = (_req, res) => {
837
+ const env = parseEnvFile();
838
+ res.json({
839
+ url: env.DATABASE_URL ?? "",
840
+ username: env.DATABASE_USERNAME ?? "",
841
+ password: env.DATABASE_PASSWORD ? "***" : "",
842
+ });
843
+ };
844
+
845
+ const handleConnectionsTest: RouteHandler = async (req, res) => {
846
+ const body = req.body as Record<string, string> | undefined;
847
+ const url = body?.url ?? "";
848
+ const username = body?.username ?? "";
849
+ const password = body?.password ?? "";
850
+ if (!url) {
851
+ res.json({ success: false, error: "No connection URL provided" });
852
+ return;
853
+ }
854
+ try {
855
+ // Try to use the ORM's initDatabase if available
856
+ const { initDatabase } = await import("@tina4/orm").catch(() => ({ initDatabase: null }));
857
+ if (!initDatabase) {
858
+ res.json({ success: false, error: "Database module (@tina4/orm) not available" });
859
+ return;
860
+ }
861
+ const db = await initDatabase({ url, username, password });
862
+ let version = "Connected";
863
+ let tableCount = 0;
864
+ try {
865
+ if (db.tables) {
866
+ const tables = await db.tables();
867
+ tableCount = Array.isArray(tables) ? tables.length : 0;
868
+ }
869
+ } catch { tableCount = 0; }
870
+ try {
871
+ const urlLower = url.toLowerCase();
872
+ if (urlLower.includes("sqlite")) {
873
+ const row = await db.execute("SELECT sqlite_version() as v");
874
+ version = `SQLite ${row?.[0]?.v ?? ""}`;
875
+ } else if (urlLower.includes("postgres")) {
876
+ const row = await db.execute("SELECT version() as v");
877
+ version = (row?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
878
+ } else if (urlLower.includes("mysql")) {
879
+ const row = await db.execute("SELECT version() as v");
880
+ version = `MySQL ${row?.[0]?.v ?? ""}`;
881
+ } else if (urlLower.includes("mssql")) {
882
+ const row = await db.execute("SELECT @@VERSION as v");
883
+ version = (row?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
884
+ } else if (urlLower.includes("firebird")) {
885
+ const row = await db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
886
+ version = `Firebird ${row?.[0]?.v ?? ""}`;
887
+ }
888
+ } catch { /* keep version as Connected */ }
889
+ if (db.close) await db.close();
890
+ res.json({ success: true, version, tables: tableCount });
891
+ } catch (e: unknown) {
892
+ const msg = e instanceof Error ? e.message : String(e);
893
+ res.json({ success: false, error: msg });
894
+ }
895
+ };
896
+
897
+ const handleConnectionsSave: RouteHandler = (req, res) => {
898
+ const body = req.body as Record<string, string> | undefined;
899
+ const url = body?.url ?? "";
900
+ const username = body?.username ?? "";
901
+ const password = body?.password ?? "";
902
+ if (!url) {
903
+ res.json({ success: false, error: "No connection URL provided" });
904
+ return;
905
+ }
906
+ try {
907
+ const envPath = join(process.cwd(), ".env");
908
+ const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
909
+ const keysFound: Record<string, boolean> = { DATABASE_URL: false, DATABASE_USERNAME: false, DATABASE_PASSWORD: false };
910
+ const newLines: string[] = [];
911
+ for (const line of lines) {
912
+ const trimmed = line.trim();
913
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
914
+ newLines.push(line);
915
+ continue;
916
+ }
917
+ const key = trimmed.split("=", 1)[0].trim();
918
+ if (key === "DATABASE_URL") { newLines.push(`DATABASE_URL=${url}`); keysFound.DATABASE_URL = true; }
919
+ else if (key === "DATABASE_USERNAME") { newLines.push(`DATABASE_USERNAME=${username}`); keysFound.DATABASE_USERNAME = true; }
920
+ else if (key === "DATABASE_PASSWORD") { newLines.push(`DATABASE_PASSWORD=${password}`); keysFound.DATABASE_PASSWORD = true; }
921
+ else { newLines.push(line); }
922
+ }
923
+ const values: Record<string, string> = { DATABASE_URL: url, DATABASE_USERNAME: username, DATABASE_PASSWORD: password };
924
+ for (const [key, found] of Object.entries(keysFound)) {
925
+ if (!found) newLines.push(`${key}=${values[key]}`);
926
+ }
927
+ writeFileSync(envPath, newLines.join("\n") + "\n");
928
+ res.json({ success: true });
929
+ } catch (e: unknown) {
930
+ const msg = e instanceof Error ? e.message : String(e);
931
+ res.json({ success: false, error: msg });
932
+ }
933
+ };
934
+
935
+ // ---------------------------------------------------------------------------
936
+ // Gallery handlers — list and deploy gallery examples
937
+ // ---------------------------------------------------------------------------
938
+
939
+ const __devAdminFilename = fileURLToPath(import.meta.url);
940
+ const __devAdminDirname = dirname(__devAdminFilename);
941
+
942
+ function walkDirRecursive(dir: string): string[] {
943
+ const results: string[] = [];
944
+ if (!existsSync(dir)) return results;
945
+ for (const entry of readdirSync(dir)) {
946
+ const full = join(dir, entry);
947
+ if (statSync(full).isDirectory()) {
948
+ results.push(...walkDirRecursive(full));
949
+ } else {
950
+ results.push(full);
951
+ }
952
+ }
953
+ return results;
954
+ }
955
+
956
+ const handleGalleryList: RouteHandler = (_req, res) => {
957
+ const galleryDir = resolve(__devAdminDirname, "..", "gallery");
958
+ const items: Array<Record<string, unknown>> = [];
959
+
960
+ if (existsSync(galleryDir)) {
961
+ const entries = readdirSync(galleryDir).sort();
962
+ for (const entry of entries) {
963
+ const entryPath = join(galleryDir, entry);
964
+ const metaFile = join(entryPath, "meta.json");
965
+ if (statSync(entryPath).isDirectory() && existsSync(metaFile)) {
966
+ try {
967
+ const meta = JSON.parse(readFileSync(metaFile, "utf-8"));
968
+ meta.id = entry;
969
+ // List the files that would be deployed
970
+ const srcDir = join(entryPath, "src");
971
+ if (existsSync(srcDir)) {
972
+ const allFiles = walkDirRecursive(srcDir);
973
+ meta.files = allFiles.map((f) => relative(srcDir, f));
974
+ }
975
+ // Check if already deployed
976
+ const projectSrc = resolve(process.cwd(), "src");
977
+ if (existsSync(srcDir) && meta.files) {
978
+ meta.deployed = (meta.files as string[]).every((f: string) =>
979
+ existsSync(join(projectSrc, f)),
980
+ );
981
+ } else {
982
+ meta.deployed = false;
983
+ }
984
+ items.push(meta);
985
+ } catch {
986
+ // Skip invalid meta.json
987
+ }
988
+ }
989
+ }
990
+ }
991
+
992
+ res.json({ gallery: items, count: items.length });
993
+ };
994
+
995
+ function handleGalleryDeploy(router: Router): RouteHandler {
996
+ return async (req, res): Promise<void> => {
997
+ const body = (req.body as Record<string, unknown>) ?? {};
998
+ const name = (body.name as string) ?? "";
999
+ if (!name) {
1000
+ res.json({ error: "No gallery item specified" }, 400);
1001
+ return;
1002
+ }
1003
+
1004
+ const galleryDir = resolve(__devAdminDirname, "..", "gallery");
1005
+ const gallerySrc = join(galleryDir, name, "src");
1006
+ if (!existsSync(gallerySrc)) {
1007
+ res.json({ error: `Gallery item '${name}' not found` }, 404);
1008
+ return;
1009
+ }
1010
+
1011
+ const projectSrc = resolve(process.cwd(), "src");
1012
+ const copied: string[] = [];
1013
+
1014
+ const allFiles = walkDirRecursive(gallerySrc);
1015
+ for (const srcFile of allFiles) {
1016
+ const rel = relative(gallerySrc, srcFile);
1017
+ const dest = join(projectSrc, rel);
1018
+ mkdirSync(dirname(dest), { recursive: true });
1019
+ copyFileSync(srcFile, dest);
1020
+ copied.push(rel);
1021
+ }
1022
+
1023
+ // Re-discover routes so new files are immediately available
1024
+ try {
1025
+ const routesDir = resolve(process.cwd(), "src", "routes");
1026
+ if (existsSync(routesDir)) {
1027
+ const { discoverRoutes } = await import("./routeDiscovery.js");
1028
+ const routes = await discoverRoutes(routesDir);
1029
+ for (const route of routes) {
1030
+ // Only add if not already registered
1031
+ const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\{\.\.\.\w+\}/g, "test"));
1032
+ if (!existing) {
1033
+ router.addRoute(route);
1034
+ }
1035
+ }
1036
+ }
1037
+ } catch {
1038
+ // Non-fatal — routes will load on next restart
1039
+ }
1040
+
1041
+ res.json({ deployed: name, files: copied });
1042
+ };
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // Dev Admin JS handler — serves the shared JS file
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ const handleDevAdminJs: RouteHandler = (_req, res) => {
1050
+ res.raw.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" });
1051
+ res.raw.end(renderDevAdminJs());
1052
+ };
1053
+
1054
+ // ---------------------------------------------------------------------------
1055
+ // Shared Dev Admin JS — cross-language, vanilla JS, zero dependencies
1056
+ // ---------------------------------------------------------------------------
1057
+
1058
+ function renderDevAdminJs(): string {
1059
+ // Use single-quoted strings and concatenation to avoid template literal escaping issues
1060
+ return [
1061
+ "let currentTab = 'routes';",
1062
+ "let queueFilter = '';",
1063
+ "let mailboxFolder = '';",
1064
+ "",
1065
+ "function showTab(tab, e) {",
1066
+ " currentTab = tab;",
1067
+ " document.querySelectorAll('.dev-tab').forEach(function(t) { t.classList.remove('active'); });",
1068
+ " document.querySelectorAll('.dev-panel').forEach(function(p) { p.classList.add('hidden'); });",
1069
+ " if (e) e.target.closest('.dev-tab').classList.add('active');",
1070
+ " document.getElementById('panel-' + tab).classList.remove('hidden');",
1071
+ " var loaders = {routes:loadRoutes, queue:loadQueue, mailbox:loadMailbox, messages:loadMessages, database:loadTables, requests:loadRequests, errors:loadErrors, websockets:loadWebSockets, system:loadSystem, tools:function(){}};",
1072
+ " if (loaders[tab]) loaders[tab]();",
1073
+ "}",
1074
+ "",
1075
+ "function api(path, method, body) {",
1076
+ " var opts = { method: method || 'GET', headers: {} };",
1077
+ " if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }",
1078
+ " return fetch(path, opts).then(function(r) { return r.json(); });",
1079
+ "}",
1080
+ "",
1081
+ "// -- Routes --",
1082
+ "function loadRoutes() {",
1083
+ " api('/__dev/api/routes').then(function(d) {",
1084
+ " document.getElementById('routes-count').textContent = d.count;",
1085
+ " document.getElementById('routes-body').innerHTML = d.routes.map(function(r) {",
1086
+ " return '<tr>' +",
1087
+ " '<td><span class=\"method method-' + r.method.toLowerCase() + '\">' + r.method + '</span></td>' +",
1088
+ " '<td class=\"path\">' + (r.path || r.pattern || '') + '</td>' +",
1089
+ " '<td>' + (r.auth_required || r.secure ? '<span class=\"badge-pill bg-reserved\">auth</span>' : '<span class=\"badge-pill bg-success\">open</span>') + '</td>' +",
1090
+ " '<td class=\"text-sm text-muted\">' + (r.handler || '') + (r.module ? ' <small>(' + r.module + ')</small>' : '') + '</td>' +",
1091
+ " '</tr>';",
1092
+ " }).join('');",
1093
+ " });",
1094
+ "}",
1095
+ "",
1096
+ "// -- Queue --",
1097
+ "function loadQueue() {",
1098
+ " var qs = queueFilter ? '?status=' + queueFilter : '';",
1099
+ " api('/__dev/api/queue' + qs).then(function(d) {",
1100
+ " ['pending','completed','failed','reserved'].forEach(function(s) {",
1101
+ " var el = document.getElementById('q-' + s);",
1102
+ " if (el) el.textContent = d.stats[s] || 0;",
1103
+ " });",
1104
+ " document.getElementById('queue-count').textContent = Object.values(d.stats).reduce(function(a,b){return a+b;}, 0);",
1105
+ " var tbody = document.getElementById('queue-body');",
1106
+ " var empty = document.getElementById('queue-empty');",
1107
+ " if (!d.jobs.length) { tbody.innerHTML = ''; empty.classList.remove('hidden'); return; }",
1108
+ " empty.classList.add('hidden');",
1109
+ " tbody.innerHTML = d.jobs.map(function(j) {",
1110
+ " return '<tr>' +",
1111
+ " '<td>' + j.id + '</td>' +",
1112
+ " '<td class=\"path\">' + j.topic + '</td>' +",
1113
+ " '<td><span class=\"badge-pill bg-' + j.status + '\">' + j.status + '</span></td>' +",
1114
+ " '<td>' + j.attempts + '</td>' +",
1115
+ " '<td class=\"text-sm text-muted\">' + (j.created_at || '') + '</td>' +",
1116
+ " '<td class=\"text-mono text-sm\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis\">' + (typeof j.data === 'object' ? JSON.stringify(j.data) : j.data) + '</td>' +",
1117
+ " '<td><button class=\"btn btn-sm\" onclick=\"replayJob(\\'' + j.id + '\\',\\'' + j.topic + '\\')\">Replay</button></td>' +",
1118
+ " '</tr>';",
1119
+ " }).join('');",
1120
+ " });",
1121
+ "}",
1122
+ "function filterQueue(status, e) {",
1123
+ " queueFilter = status;",
1124
+ " document.querySelectorAll('#panel-queue .filter-btn').forEach(function(b) { b.classList.remove('active'); });",
1125
+ " if (e) e.target.classList.add('active');",
1126
+ " loadQueue();",
1127
+ "}",
1128
+ "function retryQueue() { api('/__dev/api/queue/retry', 'POST', {}).then(function() { loadQueue(); }); }",
1129
+ "function purgeQueue() { api('/__dev/api/queue/purge', 'POST', {}).then(function() { loadQueue(); }); }",
1130
+ "function replayJob(id, topic) { api('/__dev/api/queue/replay', 'POST', {job_id: id, topic: topic}).then(function() { loadQueue(); }); }",
1131
+ "",
1132
+ "// -- Mailbox --",
1133
+ "function loadMailbox() {",
1134
+ " var qs = mailboxFolder ? '?folder=' + mailboxFolder : '';",
1135
+ " api('/__dev/api/mailbox' + qs).then(function(d) {",
1136
+ " document.getElementById('mailbox-count').textContent = d.unread;",
1137
+ " document.getElementById('mail-detail').classList.add('hidden');",
1138
+ " var list = document.getElementById('mailbox-list');",
1139
+ " if (!d.messages.length) { list.innerHTML = '<div class=\"empty\">No messages. Click \"Seed 5\" to generate test emails.</div>'; return; }",
1140
+ " list.innerHTML = d.messages.map(function(m) {",
1141
+ " return '<div class=\"mail-item ' + (m.read ? '' : 'unread') + '\" onclick=\"readMail(\\'' + m.id + '\\')\">'+",
1142
+ " '<span class=\"text-sm text-muted\" style=\"float:right\">' + (m.date||'').substring(0,16) + '</span>'+",
1143
+ " '<div class=\"text-sm text-muted\">' + m.from + ' &rarr; ' + (m.to||[]).join(', ') + '</div>'+",
1144
+ " '<div style=\"font-weight:600;font-size:0.8rem\">' + m.subject + '</div>'+",
1145
+ " '<span class=\"badge-pill bg-' + (m.type === 'inbox' ? 'success' : 'primary') + '\" style=\"margin-top:0.2rem\">' + m.type + '</span>'+",
1146
+ " '</div>';",
1147
+ " }).join('');",
1148
+ " });",
1149
+ "}",
1150
+ "function filterMailbox(folder, e) {",
1151
+ " mailboxFolder = folder;",
1152
+ " document.querySelectorAll('#panel-mailbox .filter-btn').forEach(function(b) { b.classList.remove('active'); });",
1153
+ " if (e) e.target.classList.add('active');",
1154
+ " loadMailbox();",
1155
+ "}",
1156
+ "function readMail(id) {",
1157
+ " api('/__dev/api/mailbox/read?id=' + id).then(function(m) {",
1158
+ " var det = document.getElementById('mail-detail');",
1159
+ " det.classList.remove('hidden');",
1160
+ " det.innerHTML = '<h3 style=\"font-size:0.9rem\">' + m.subject + '</h3>'+",
1161
+ " '<p class=\"text-sm text-muted\">From: ' + m.from + ' | To: ' + (m.to||[]).join(', ') + ' | ' + m.date + '</p>'+",
1162
+ " '<div style=\"background:var(--bg);padding:0.75rem;border-radius:var(--radius);margin-top:0.5rem;font-size:0.8rem\">' + (m.html ? m.body : '<pre>' + (m.body||'') + '</pre>') + '</div>';",
1163
+ " });",
1164
+ "}",
1165
+ "function seedMailbox() { api('/__dev/api/mailbox/seed', 'POST', {count:5}).then(function() { loadMailbox(); }); }",
1166
+ "function clearMailbox() { api('/__dev/api/mailbox/clear', 'POST', {}).then(function() { loadMailbox(); }); }",
1167
+ "",
1168
+ "// -- Messages --",
1169
+ "function loadMessages() {",
1170
+ " api('/__dev/api/messages').then(function(d) {",
1171
+ " document.getElementById('messages-count').textContent = d.counts.total || 0;",
1172
+ " renderMessages(d.messages);",
1173
+ " });",
1174
+ "}",
1175
+ "function searchMessages() {",
1176
+ " var q = document.getElementById('msg-search').value.trim();",
1177
+ " if (!q) { loadMessages(); return; }",
1178
+ " api('/__dev/api/messages/search?q=' + encodeURIComponent(q)).then(function(d) { renderMessages(d.messages); });",
1179
+ "}",
1180
+ "function renderMessages(messages) {",
1181
+ " var list = document.getElementById('messages-list');",
1182
+ " var empty = document.getElementById('messages-empty');",
1183
+ " if (!messages.length) { list.innerHTML = ''; empty.classList.remove('hidden'); return; }",
1184
+ " empty.classList.add('hidden');",
1185
+ " list.innerHTML = messages.map(function(m) {",
1186
+ " return '<div class=\"msg-entry\">'+",
1187
+ " '<span class=\"time\">' + (m.timestamp||'').substring(11,19) + '</span> '+",
1188
+ " '<span class=\"cat\">' + m.category + '</span> '+",
1189
+ " '<span class=\"level-' + m.level + '\">[' + m.level + ']</span> '+",
1190
+ " esc(m.message) +",
1191
+ " (m.data ? ' <code class=\"text-sm text-muted\">' + JSON.stringify(m.data) + '</code>' : '') +",
1192
+ " '</div>';",
1193
+ " }).join('');",
1194
+ "}",
1195
+ "function clearMessages() { api('/__dev/api/messages/clear', 'POST', {}).then(function() { loadMessages(); }); }",
1196
+ "",
1197
+ "// -- Database --",
1198
+ "function loadTables() {",
1199
+ " api('/__dev/api/tables').then(function(d) {",
1200
+ " var tables = d.tables || [];",
1201
+ " document.getElementById('db-count').textContent = tables.length;",
1202
+ " document.getElementById('table-list').innerHTML = tables.map(function(t) {",
1203
+ " return '<div style=\"padding:0.2rem 0.4rem;cursor:pointer;border-radius:0.25rem\" onclick=\"browseTable(\\'' + t + '\\')\" onmouseover=\"this.style.background=\\'rgba(46,125,50,0.1)\\'\" onmouseout=\"this.style.background=\\'\\'\">' + t + '</div>';",
1204
+ " }).join('');",
1205
+ " var sel = document.getElementById('seed-table');",
1206
+ " sel.innerHTML = '<option value=\"\">Pick table...</option>' + tables.map(function(t) { return '<option value=\"' + t + '\">' + t + '</option>'; }).join('');",
1207
+ " });",
1208
+ "}",
1209
+ "function browseTable(name) { document.getElementById('query-input').value = 'SELECT * FROM ' + name + ' LIMIT 20'; runQuery(); }",
1210
+ "function seedTable() {",
1211
+ " var table = document.getElementById('seed-table').value;",
1212
+ " var count = parseInt(document.getElementById('seed-count').value) || 10;",
1213
+ " if (!table) return;",
1214
+ " api('/__dev/api/seed', 'POST', {table:table, count:count}).then(function(d) {",
1215
+ " if (d.error) { alert(d.error); return; }",
1216
+ " browseTable(table);",
1217
+ " });",
1218
+ "}",
1219
+ "function runQuery() {",
1220
+ " var query = document.getElementById('query-input').value.trim();",
1221
+ " var type = document.getElementById('query-type').value;",
1222
+ " var errorEl = document.getElementById('query-error');",
1223
+ " errorEl.classList.add('hidden');",
1224
+ " if (!query) return;",
1225
+ " api('/__dev/api/query', 'POST', {query:query, type:type}).then(function(d) {",
1226
+ " if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); return; }",
1227
+ " var results = document.getElementById('query-results');",
1228
+ " if (d.rows && d.rows.length) {",
1229
+ " var cols = d.columns || Object.keys(d.rows[0]);",
1230
+ " results.innerHTML = '<div class=\"text-sm text-muted p-sm\">' + (d.count||d.rows.length) + ' rows</div>' +",
1231
+ " '<table><thead><tr>' + cols.map(function(c){return '<th>'+c+'</th>';}).join('') + '</tr></thead>' +",
1232
+ " '<tbody>' + d.rows.map(function(r){ return '<tr>' + cols.map(function(c){ return '<td class=\"text-mono text-sm\">' + (r[c]===null?'<span class=\"text-muted\">NULL</span>':esc(String(r[c]))) + '</td>'; }).join('') + '</tr>'; }).join('') + '</tbody></table>';",
1233
+ " } else if (d.data) {",
1234
+ " results.innerHTML = '<pre class=\"p-md text-mono text-sm\">' + JSON.stringify(d.data, null, 2) + '</pre>';",
1235
+ " } else if (d.success) {",
1236
+ " results.innerHTML = '<div class=\"empty\">Query executed. ' + (d.affected||0) + ' rows affected.</div>';",
1237
+ " } else {",
1238
+ " results.innerHTML = '<div class=\"empty\">No results</div>';",
1239
+ " }",
1240
+ " }).catch(function(e) { errorEl.textContent = e.message; errorEl.classList.remove('hidden'); });",
1241
+ "}",
1242
+ "",
1243
+ "// -- Requests --",
1244
+ "function loadRequests() {",
1245
+ " api('/__dev/api/requests').then(function(d) {",
1246
+ " var stats = d.stats || {};",
1247
+ " document.getElementById('req-count').textContent = stats.total || 0;",
1248
+ " document.getElementById('req-stats').innerHTML = 'Total: ' + (stats.total||0) + ' | Avg: ' + (stats.avg_ms||0) + 'ms | Errors: ' + (stats.errors||0) + ' | Slowest: ' + (stats.slowest_ms||0) + 'ms';",
1249
+ " var tbody = document.getElementById('req-body');",
1250
+ " var empty = document.getElementById('req-empty');",
1251
+ " if (!(d.requests||[]).length) { tbody.innerHTML = ''; empty.classList.remove('hidden'); return; }",
1252
+ " empty.classList.add('hidden');",
1253
+ " tbody.innerHTML = d.requests.map(function(r) {",
1254
+ " var sc = r.status >= 500 ? 'status-err' : r.status >= 400 ? 'status-warn' : 'status-ok';",
1255
+ " return '<tr>'+",
1256
+ " '<td class=\"text-sm text-muted text-mono\">' + (r.timestamp||'').substring(11,19) + '</td>'+",
1257
+ " '<td><span class=\"method method-' + r.method.toLowerCase() + '\">' + r.method + '</span></td>'+",
1258
+ " '<td class=\"path\">' + r.path + '</td>'+",
1259
+ " '<td class=\"' + sc + '\" style=\"font-weight:600\">' + r.status + '</td>'+",
1260
+ " '<td class=\"text-mono text-sm\">' + r.duration_ms + 'ms</td>'+",
1261
+ " '<td class=\"text-sm text-muted\">' + (r.body_size ? r.body_size + 'B' : '') + '</td>'+",
1262
+ " '</tr>';",
1263
+ " }).join('');",
1264
+ " });",
1265
+ "}",
1266
+ "function clearRequests() { api('/__dev/api/requests/clear', 'POST', {}).then(function() { loadRequests(); }); }",
1267
+ "",
1268
+ "// -- Errors --",
1269
+ "function loadErrors() {",
1270
+ " api('/__dev/api/broken').then(function(d) {",
1271
+ " var health = d.health || {};",
1272
+ " document.getElementById('err-count').textContent = health.unresolved || 0;",
1273
+ " var list = document.getElementById('errors-list');",
1274
+ " var empty = document.getElementById('errors-empty');",
1275
+ " if (!(d.errors||[]).length) { list.innerHTML = ''; empty.classList.remove('hidden'); return; }",
1276
+ " empty.classList.add('hidden');",
1277
+ " list.innerHTML = d.errors.map(function(e) {",
1278
+ " return '<div style=\"padding:0.6rem 0.75rem;border-bottom:1px solid var(--border)\">'+",
1279
+ " '<div class=\"flex justify-between items-center\">'+",
1280
+ " '<span class=\"badge-pill ' + (e.resolved ? 'bg-success' : 'bg-danger') + '\">' + (e.resolved ? 'resolved' : 'unresolved') + '</span>'+",
1281
+ " '<span class=\"text-sm text-muted\">x' + e.count + ' | ' + (e.last_seen||'').substring(0,19) + '</span>'+",
1282
+ " '</div>'+",
1283
+ " '<div style=\"font-weight:600;font-size:0.8rem;margin-top:0.25rem\">' + esc(e.error_type) + ': ' + esc(e.message) + '</div>'+",
1284
+ " (e.traceback ? '<pre class=\"text-sm text-muted\" style=\"margin-top:0.25rem;max-height:100px;overflow:auto\">' + esc(e.traceback) + '</pre>' : '') +",
1285
+ " (!e.resolved ? '<button class=\"btn btn-sm btn-success\" style=\"margin-top:0.25rem\" onclick=\"resolveError(\\'' + e.id + '\\')\">Resolve</button>' : '') +",
1286
+ " '<button class=\"btn btn-sm btn-primary\" style=\"margin-top:0.25rem;margin-left:0.25rem\" data-err=\"' + btoa(e.error_type + ': ' + e.message) + '\" data-tb=\"' + btoa((e.traceback||'').substring(0,500)) + '\" onclick=\"askAboutError(this)\">Ask Tina4</button>'+",
1287
+ " '</div>';",
1288
+ " }).join('');",
1289
+ " });",
1290
+ "}",
1291
+ "function resolveError(id) { api('/__dev/api/broken/resolve', 'POST', {id:id}).then(function() { loadErrors(); }); }",
1292
+ "function clearResolvedErrors() { api('/__dev/api/broken/clear', 'POST', {}).then(function() { loadErrors(); }); }",
1293
+ "",
1294
+ "// -- WebSockets --",
1295
+ "function loadWebSockets() {",
1296
+ " api('/__dev/api/websockets').then(function(d) {",
1297
+ " document.getElementById('ws-count').textContent = d.count || 0;",
1298
+ " var tbody = document.getElementById('ws-body');",
1299
+ " var empty = document.getElementById('ws-empty');",
1300
+ " if (!(d.connections||[]).length) { tbody.innerHTML = ''; empty.classList.remove('hidden'); return; }",
1301
+ " empty.classList.add('hidden');",
1302
+ " tbody.innerHTML = d.connections.map(function(c) {",
1303
+ " return '<tr>'+",
1304
+ " '<td class=\"text-mono text-sm\">' + c.id + '</td>'+",
1305
+ " '<td class=\"path\">' + c.path + '</td>'+",
1306
+ " '<td class=\"text-sm text-muted\">' + c.ip + '</td>'+",
1307
+ " '<td class=\"text-sm text-muted\">' + (c.connected_at||'').substring(11,19) + '</td>'+",
1308
+ " '<td><span class=\"badge-pill ' + (c.closed ? 'bg-danger' : 'bg-success') + '\">' + (c.closed ? 'closed' : 'active') + '</span></td>'+",
1309
+ " '<td>' + (!c.closed ? '<button class=\"btn btn-sm btn-danger\" onclick=\"wsDisconnect(\\'' + c.id + '\\')\">Disconnect</button>' : '') + '</td>'+",
1310
+ " '</tr>';",
1311
+ " }).join('');",
1312
+ " });",
1313
+ "}",
1314
+ "function wsDisconnect(id) { api('/__dev/api/websockets/disconnect', 'POST', {id:id}).then(function() { loadWebSockets(); }); }",
1315
+ "",
1316
+ "// -- System --",
1317
+ "function loadSystem() {",
1318
+ " api('/__dev/api/system').then(function(d) {",
1319
+ " var nodeVersion = d.node_version || (d.node ? d.node.version : '') || 'N/A';",
1320
+ " var platform = d.os || d.platform || (d.node ? d.node.platform : '') || '';",
1321
+ " var arch = d.architecture || (d.node ? d.node.arch : '') || '';",
1322
+ " var memCurrent = d.memory ? (d.memory.current_mb ? d.memory.current_mb + ' MB' : d.memory.heapUsed || 'N/A') : 'N/A';",
1323
+ " var memPeak = d.memory ? (d.memory.peak_mb ? d.memory.peak_mb + ' MB' : d.memory.rss || 'N/A') : 'N/A';",
1324
+ " var memLimit = d.memory ? (d.memory.limit || 'N/A') : 'N/A';",
1325
+ " var fwName = d.framework ? (typeof d.framework === 'object' ? d.framework.name : d.framework) : '';",
1326
+ " var fwVersion = d.framework ? (typeof d.framework === 'object' ? d.framework.version : '') : '';",
1327
+ " var routeCount = d.framework ? (typeof d.framework === 'object' ? d.framework.route_count : '') : (d.route_count || '');",
1328
+ "",
1329
+ " var html = '<div class=\"sys-card\"><div class=\"label\">Node.js</div><div class=\"value text-sm\">' + nodeVersion + '</div></div>' +",
1330
+ " '<div class=\"sys-card\"><div class=\"label\">Platform</div><div class=\"value text-sm\">' + platform + '</div></div>' +",
1331
+ " '<div class=\"sys-card\"><div class=\"label\">Architecture</div><div class=\"value text-sm\">' + arch + '</div></div>' +",
1332
+ " '<div class=\"sys-card\"><div class=\"label\">Memory (Current)</div><div class=\"value\">' + memCurrent + '</div></div>' +",
1333
+ " '<div class=\"sys-card\"><div class=\"label\">Memory (Peak)</div><div class=\"value\">' + memPeak + '</div></div>' +",
1334
+ " '<div class=\"sys-card\"><div class=\"label\">Memory Limit</div><div class=\"value text-sm\">' + memLimit + '</div></div>';",
1335
+ "",
1336
+ " if (fwName) html += '<div class=\"sys-card\"><div class=\"label\">Framework</div><div class=\"value text-sm\">' + fwName + '</div></div>';",
1337
+ " if (fwVersion) html += '<div class=\"sys-card\"><div class=\"label\">Version</div><div class=\"value text-sm\">' + fwVersion + '</div></div>';",
1338
+ " if (routeCount !== '') html += '<div class=\"sys-card\"><div class=\"label\">Routes</div><div class=\"value\">' + routeCount + '</div></div>';",
1339
+ "",
1340
+ " if (d.node && d.node.v8) html += '<div class=\"sys-card\"><div class=\"label\">V8 Engine</div><div class=\"value text-sm\">' + d.node.v8 + '</div></div>';",
1341
+ " if (d.pid) html += '<div class=\"sys-card\"><div class=\"label\">PID</div><div class=\"value text-sm\">' + d.pid + '</div></div>';",
1342
+ " if (d.cpus) html += '<div class=\"sys-card\"><div class=\"label\">CPU Cores</div><div class=\"value\">' + d.cpus + '</div></div>';",
1343
+ " if (d.uptime) html += '<div class=\"sys-card\"><div class=\"label\">Uptime</div><div class=\"value text-sm\">' + (d.uptime.formatted || d.uptime.seconds + 's') + '</div></div>';",
1344
+ " if (d.debug_level) html += '<div class=\"sys-card\"><div class=\"label\">Debug Level</div><div class=\"value text-sm\">' + d.debug_level + '</div></div>';",
1345
+ " if (d.memory && d.memory.heapTotal) html += '<div class=\"sys-card\"><div class=\"label\">Heap Total</div><div class=\"value text-sm\">' + d.memory.heapTotal + '</div></div>';",
1346
+ " if (d.memory && d.memory.external) html += '<div class=\"sys-card\"><div class=\"label\">External</div><div class=\"value text-sm\">' + d.memory.external + '</div></div>';",
1347
+ " if (d.env) html += '<div class=\"sys-card\"><div class=\"label\">TINA4_DEBUG</div><div class=\"value text-sm\">' + (d.env.TINA4_DEBUG || 'false') + '</div></div>';",
1348
+ "",
1349
+ " document.getElementById('sys-cards').innerHTML = html;",
1350
+ " });",
1351
+ "}",
1352
+ "",
1353
+ "// -- Chat (Tina4) --",
1354
+ "var _aiKey = '';",
1355
+ "var _aiProvider = 'anthropic';",
1356
+ "function setAiKey() {",
1357
+ " _aiKey = document.getElementById('ai-key').value.trim();",
1358
+ " _aiProvider = document.getElementById('ai-provider').value;",
1359
+ " document.getElementById('ai-key').value = '';",
1360
+ " document.getElementById('ai-status').textContent = _aiKey ? (_aiProvider === 'anthropic' ? 'Claude key set' : 'OpenAI key set') : 'No key set';",
1361
+ " document.getElementById('ai-status').style.color = _aiKey ? 'var(--success)' : 'var(--muted)';",
1362
+ "}",
1363
+ "function sendChat() {",
1364
+ " var input = document.getElementById('chat-input');",
1365
+ " var msg = input.value.trim();",
1366
+ " if (!msg) return;",
1367
+ " input.value = '';",
1368
+ " var container = document.getElementById('chat-messages');",
1369
+ " container.innerHTML += '<div class=\"chat-msg chat-user\">' + esc(msg) + '</div>';",
1370
+ " container.innerHTML += '<div class=\"chat-msg chat-bot\" id=\"chat-loading\" style=\"color:var(--muted)\">Thinking...</div>';",
1371
+ " container.scrollTop = container.scrollHeight;",
1372
+ " var body = {message: msg, provider: _aiProvider};",
1373
+ " if (_aiKey) body.api_key = _aiKey;",
1374
+ " api('/__dev/api/chat', 'POST', body).then(function(d) {",
1375
+ " var loading = document.getElementById('chat-loading');",
1376
+ " if (loading) loading.remove();",
1377
+ " container.innerHTML += '<div class=\"chat-msg chat-bot\">' + formatChat(d.reply||'No response') + '</div>';",
1378
+ " container.scrollTop = container.scrollHeight;",
1379
+ " }).catch(function() {",
1380
+ " var loading = document.getElementById('chat-loading');",
1381
+ " if (loading) { loading.textContent = 'Error connecting to API'; loading.id = ''; }",
1382
+ " });",
1383
+ "}",
1384
+ "function formatChat(text) {",
1385
+ " return text.replace(/`([^`]+)`/g, '<code style=\"background:var(--surface);padding:0.1rem 0.25rem;border-radius:0.2rem;font-size:0.8em\">$1</code>').replace(/\\n/g, '<br>');",
1386
+ "}",
1387
+ "",
1388
+ "// -- Ask Tina4 about errors --",
1389
+ "function askAboutError(btn) {",
1390
+ " var error = atob(btn.dataset.err);",
1391
+ " var trace = atob(btn.dataset.tb);",
1392
+ " currentTab = 'chat';",
1393
+ " document.querySelectorAll('.dev-tab').forEach(function(t) { t.classList.remove('active'); });",
1394
+ " document.querySelectorAll('.dev-panel').forEach(function(p) { p.classList.add('hidden'); });",
1395
+ " document.querySelectorAll('.dev-tab').forEach(function(t) { if(t.textContent.includes('Tina4')) t.classList.add('active'); });",
1396
+ " document.getElementById('panel-chat').classList.remove('hidden');",
1397
+ " var msg = 'I have this error in my Tina4 app, help me fix it:\\n\\n' + error + '\\n\\nStack trace:\\n' + trace;",
1398
+ " document.getElementById('chat-input').value = msg;",
1399
+ " sendChat();",
1400
+ "}",
1401
+ "",
1402
+ "// -- Tools --",
1403
+ "function runTool(tool) {",
1404
+ " var titles = {carbon:'Carbon Benchmark',test:'Test Suite',routes:'Routes',migrate:'Migrations',seed:'Seeders',ai:'AI Detection'};",
1405
+ " document.getElementById('tool-title').textContent = titles[tool] || tool;",
1406
+ " document.getElementById('tool-result').textContent = 'Running...';",
1407
+ " document.getElementById('tool-output').classList.remove('hidden');",
1408
+ " api('/__dev/api/tool', 'POST', {tool:tool}).then(function(d) {",
1409
+ " document.getElementById('tool-result').textContent = d.output || d.error || JSON.stringify(d, null, 2);",
1410
+ " }).catch(function(e) {",
1411
+ " document.getElementById('tool-result').textContent = 'Error: ' + e.message;",
1412
+ " });",
1413
+ "}",
1414
+ "",
1415
+ "// -- Exit Dev Admin --",
1416
+ "function exitDevAdmin() {",
1417
+ " if (document.referrer && !document.referrer.includes('/__dev')) { window.location.href = document.referrer; }",
1418
+ " else if (window.history.length > 1) { window.history.back(); }",
1419
+ " else { window.location.href = '/'; }",
1420
+ "}",
1421
+ "",
1422
+ "// -- Utilities --",
1423
+ "function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }",
1424
+ "",
1425
+ "document.addEventListener('keydown', function(e) {",
1426
+ " if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && currentTab === 'database') { e.preventDefault(); runQuery(); }",
1427
+ "});",
1428
+ "",
1429
+ "// Init",
1430
+ "function updateTimestamp() { document.getElementById('timestamp').textContent = new Date().toLocaleTimeString(); }",
1431
+ "setInterval(updateTimestamp, 1000);",
1432
+ "updateTimestamp();",
1433
+ "loadRoutes();",
1434
+ "",
1435
+ "api('/__dev/api/status').then(function(d) {",
1436
+ " if (d.mailbox) document.getElementById('mailbox-count').textContent = d.mailbox.total || 0;",
1437
+ " if (d.messages) document.getElementById('messages-count').textContent = d.messages.total || 0;",
1438
+ " if (d.message_counts) document.getElementById('messages-count').textContent = d.message_counts.total || 0;",
1439
+ " if (d.health) document.getElementById('err-count').textContent = d.health.unresolved || 0;",
1440
+ " if (d.requests) document.getElementById('req-count').textContent = d.requests.total || 0;",
1441
+ " if (d.request_stats) document.getElementById('req-count').textContent = d.request_stats.total || 0;",
1442
+ "});",
1443
+ ].join("\n");
1444
+ }
1445
+
1446
+ // ---------------------------------------------------------------------------
1447
+ // Dashboard HTML — Single-page app
1448
+ // ---------------------------------------------------------------------------
1449
+
1450
+ function renderDashboard(): string {
1451
+ return `<!DOCTYPE html>
1452
+ <html lang="en">
1453
+ <head>
1454
+ <meta charset="UTF-8">
1455
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1456
+ <title>Tina4 Dev Admin</title>
1457
+ <style>
1458
+ :root {
1459
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
1460
+ --text: #e2e8f0; --muted: #94a3b8; --primary: #2e7d32;
1461
+ --success: #22c55e; --danger: #ef4444; --warn: #f59e0b;
1462
+ --info: #06b6d4; --radius: 0.5rem;
1463
+ --mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
1464
+ --font: system-ui, -apple-system, sans-serif;
1465
+ }
1466
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1467
+
1468
+ body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 0.875rem; }
1469
+ .dev-header {
1470
+ background: var(--surface); border-bottom: 1px solid var(--border);
1471
+ padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem;
1472
+ }
1473
+ .dev-header h1 { font-size: 1rem; font-weight: 600; }
1474
+ .dev-header .badge {
1475
+ background: var(--primary); color: #fff; padding: 0.15rem 0.5rem;
1476
+ border-radius: 1rem; font-size: 0.7rem; font-weight: 600;
1477
+ }
1478
+ .dev-tabs {
1479
+ display: flex; gap: 0; background: var(--surface);
1480
+ border-bottom: 1px solid var(--border); overflow-x: auto;
1481
+ }
1482
+ .dev-tab {
1483
+ padding: 0.6rem 1rem; cursor: pointer; font-size: 0.8rem;
1484
+ border-bottom: 2px solid transparent; color: var(--muted);
1485
+ transition: all 0.15s; background: none; border-top: none;
1486
+ border-left: none; border-right: none; white-space: nowrap;
1487
+ }
1488
+ .dev-tab:hover { color: var(--text); }
1489
+ .dev-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
1490
+ .dev-tab .count {
1491
+ background: var(--border); color: var(--muted); padding: 0.1rem 0.4rem;
1492
+ border-radius: 0.75rem; font-size: 0.65rem; margin-left: 0.25rem;
1493
+ }
1494
+ .dev-content { padding: 1rem; max-width: 1400px; }
1495
+ .dev-panel {
1496
+ background: var(--surface); border: 1px solid var(--border);
1497
+ border-radius: var(--radius); overflow: hidden;
1498
+ }
1499
+ .dev-panel-header {
1500
+ padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
1501
+ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;
1502
+ }
1503
+ .dev-panel-header h2 { font-size: 0.9rem; font-weight: 600; }
1504
+ table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
1505
+ th { text-align: left; padding: 0.5rem 0.75rem; color: var(--muted); font-weight: 500; border-bottom: 1px solid var(--border); }
1506
+ td { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); }
1507
+ tr:hover { background: rgba(46, 125, 50, 0.05); }
1508
+ .method { font-family: var(--mono); font-size: 0.7rem; font-weight: 700; }
1509
+ .method-get { color: var(--success); }
1510
+ .method-post { color: var(--primary); }
1511
+ .method-put { color: var(--warn); }
1512
+ .method-delete { color: var(--danger); }
1513
+ .path { font-family: var(--mono); font-size: 0.75rem; }
1514
+ .badge-pill {
1515
+ display: inline-block; padding: 0.1rem 0.5rem; border-radius: 1rem;
1516
+ font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
1517
+ }
1518
+ .bg-pending { background: rgba(245,158,11,0.15); color: var(--warn); }
1519
+ .bg-completed, .bg-success { background: rgba(34,197,94,0.15); color: var(--success); }
1520
+ .bg-failed, .bg-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
1521
+ .bg-reserved, .bg-primary { background: rgba(46,125,50,0.15); color: var(--primary); }
1522
+ .bg-info { background: rgba(6,182,212,0.15); color: var(--info); }
1523
+ .btn {
1524
+ padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius);
1525
+ background: var(--surface); color: var(--text); cursor: pointer; font-size: 0.75rem;
1526
+ transition: all 0.15s;
1527
+ }
1528
+ .btn:hover { border-color: var(--primary); color: var(--primary); }
1529
+ .btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
1530
+ .btn-primary:hover { background: #388e3c; }
1531
+ .btn-danger { border-color: var(--danger); color: var(--danger); }
1532
+ .btn-danger:hover { background: rgba(239,68,68,0.1); }
1533
+ .btn-success { border-color: var(--success); color: var(--success); }
1534
+ .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.7rem; }
1535
+ .empty { padding: 2rem; text-align: center; color: var(--muted); }
1536
+ .input {
1537
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
1538
+ border-radius: var(--radius); padding: 0.35rem 0.5rem; font-size: 0.8rem;
1539
+ font-family: var(--font);
1540
+ }
1541
+ .input:focus { outline: none; border-color: var(--primary); }
1542
+ .input-mono { font-family: var(--mono); }
1543
+ select.input { padding: 0.3rem; }
1544
+ textarea.input { resize: vertical; font-family: var(--mono); }
1545
+ .flex { display: flex; }
1546
+ .gap-sm { gap: 0.5rem; }
1547
+ .gap-md { gap: 1rem; }
1548
+ .items-center { align-items: center; }
1549
+ .justify-between { justify-content: space-between; }
1550
+ .flex-1 { flex: 1; }
1551
+ .p-sm { padding: 0.5rem; }
1552
+ .p-md { padding: 1rem; }
1553
+ .mb-sm { margin-bottom: 0.5rem; }
1554
+ .text-sm { font-size: 0.75rem; }
1555
+ .text-muted { color: var(--muted); }
1556
+ .text-mono { font-family: var(--mono); }
1557
+ .mail-item { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); cursor: pointer; }
1558
+ .mail-item:hover { background: rgba(46,125,50,0.05); }
1559
+ .mail-item.unread { border-left: 3px solid var(--primary); }
1560
+ .msg-entry { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.75rem; }
1561
+ .msg-entry .cat {
1562
+ font-family: var(--mono); font-size: 0.65rem; padding: 0.1rem 0.35rem;
1563
+ border-radius: 0.25rem; background: rgba(46,125,50,0.15); color: var(--primary);
1564
+ }
1565
+ .msg-entry .time { color: var(--muted); font-size: 0.7rem; font-family: var(--mono); }
1566
+ .level-error { color: var(--danger); }
1567
+ .level-warn { color: var(--warn); }
1568
+ .toolbar { display: flex; gap: 0.5rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; }
1569
+ .hidden { display: none; }
1570
+ /* Chat panel */
1571
+ .chat-container { display: flex; flex-direction: column; height: 500px; }
1572
+ .chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
1573
+ .chat-msg { margin-bottom: 0.75rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.8rem; max-width: 85%; }
1574
+ .chat-user { background: var(--primary); color: #fff; margin-left: auto; }
1575
+ .chat-bot { background: var(--bg); border: 1px solid var(--border); }
1576
+ .chat-input-row { display: flex; gap: 0.5rem; padding: 0.75rem; border-top: 1px solid var(--border); }
1577
+ .chat-input-row input { flex: 1; }
1578
+ /* System cards */
1579
+ .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem; padding: 1rem; }
1580
+ .sys-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.75rem; }
1581
+ .sys-card .label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
1582
+ .sys-card .value { font-size: 1.25rem; font-weight: 600; margin-top: 0.25rem; }
1583
+ /* Request table */
1584
+ .status-ok { color: var(--success); }
1585
+ .status-err { color: var(--danger); }
1586
+ .status-warn { color: var(--warn); }
1587
+ /* Extension tags */
1588
+ .ext-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
1589
+ .ext-tag { background: rgba(46, 125, 50, 0.15); color: #81c784; padding: 3px 10px; border-radius: 12px; font-size: 0.78em; }
1590
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin-bottom: 0.75rem; }
1591
+ .card h3 { color: #81c784; margin-bottom: 0.75rem; font-size: 0.95rem; }
1592
+ .sys-item { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
1593
+ .sys-label { color: var(--muted); font-size: 0.82rem; }
1594
+ .sys-value { font-weight: 500; font-size: 0.82rem; }
1595
+ code, .mono { font-family: var(--mono); font-size: 0.82rem; }
1596
+ </style>
1597
+ </head>
1598
+ <body>
1599
+
1600
+ <div class="dev-header">
1601
+ <img src="/images/logo.svg" style="width:1.5rem;height:1.5rem;cursor:pointer;opacity:0.7;transition:opacity 0.15s" title="Back to app" onclick="exitDevAdmin()" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'" alt="Tina4">
1602
+ <h1>Tina4 Dev Admin</h1>
1603
+ <span class="badge">DEV</span>
1604
+ <span style="margin-left:auto; font-size:0.75rem; color:var(--muted)" id="timestamp"></span>
1605
+ </div>
1606
+
1607
+ <div class="dev-tabs">
1608
+ <button class="dev-tab active" onclick="showTab('routes', event)">Routes <span class="count" id="routes-count">0</span></button>
1609
+ <button class="dev-tab" onclick="showTab('queue', event)">Queue <span class="count" id="queue-count">0</span></button>
1610
+ <button class="dev-tab" onclick="showTab('mailbox', event)">Mailbox <span class="count" id="mailbox-count">0</span></button>
1611
+ <button class="dev-tab" onclick="showTab('messages', event)">Messages <span class="count" id="messages-count">0</span></button>
1612
+ <button class="dev-tab" onclick="showTab('database', event)">Database <span class="count" id="db-count">0</span></button>
1613
+ <button class="dev-tab" onclick="showTab('requests', event)">Requests <span class="count" id="req-count">0</span></button>
1614
+ <button class="dev-tab" onclick="showTab('errors', event)">Errors <span class="count" id="err-count">0</span></button>
1615
+ <button class="dev-tab" onclick="showTab('websockets', event)">WS <span class="count" id="ws-count">0</span></button>
1616
+ <button class="dev-tab" onclick="showTab('system', event)">System</button>
1617
+ <button class="dev-tab" onclick="showTab('tools', event)">Tools</button>
1618
+ <button class="dev-tab" onclick="showTab('connections', event)">Connections</button>
1619
+ <button class="dev-tab" onclick="showTab('chat', event)">Tina4</button>
1620
+ </div>
1621
+
1622
+ <div class="dev-content">
1623
+
1624
+ <!-- Routes Panel -->
1625
+ <div id="panel-routes" class="dev-panel">
1626
+ <div class="dev-panel-header">
1627
+ <h2>Registered Routes</h2>
1628
+ <button class="btn btn-sm" onclick="loadRoutes()">Refresh</button>
1629
+ </div>
1630
+ <table>
1631
+ <thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Handler</th></tr></thead>
1632
+ <tbody id="routes-body"></tbody>
1633
+ </table>
1634
+ </div>
1635
+
1636
+ <!-- Queue Panel -->
1637
+ <div id="panel-queue" class="dev-panel hidden">
1638
+ <div class="dev-panel-header">
1639
+ <h2>Queue Jobs</h2>
1640
+ <div class="flex gap-sm">
1641
+ <button class="btn btn-sm" onclick="loadQueue()">Refresh</button>
1642
+ <button class="btn btn-sm" onclick="retryQueue()">Retry Failed</button>
1643
+ <button class="btn btn-sm btn-danger" onclick="purgeQueue()">Purge Done</button>
1644
+ </div>
1645
+ </div>
1646
+ <div class="toolbar">
1647
+ <button class="btn btn-sm filter-btn active" onclick="filterQueue('', event)">All</button>
1648
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('pending', event)">Pending <span id="q-pending">0</span></button>
1649
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('completed', event)">Done <span id="q-completed">0</span></button>
1650
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('failed', event)">Failed <span id="q-failed">0</span></button>
1651
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('reserved', event)">Active <span id="q-reserved">0</span></button>
1652
+ </div>
1653
+ <table>
1654
+ <thead><tr><th>ID</th><th>Topic</th><th>Status</th><th>Attempts</th><th>Created</th><th>Data</th><th></th></tr></thead>
1655
+ <tbody id="queue-body"></tbody>
1656
+ </table>
1657
+ <div id="queue-empty" class="empty hidden">No queue jobs</div>
1658
+ </div>
1659
+
1660
+ <!-- Mailbox Panel -->
1661
+ <div id="panel-mailbox" class="dev-panel hidden">
1662
+ <div class="dev-panel-header">
1663
+ <h2>Dev Mailbox</h2>
1664
+ <div class="flex gap-sm">
1665
+ <button class="btn btn-sm" onclick="loadMailbox()">Refresh</button>
1666
+ <button class="btn btn-sm btn-primary" onclick="seedMailbox()">Seed 5</button>
1667
+ <button class="btn btn-sm btn-danger" onclick="clearMailbox()">Clear</button>
1668
+ </div>
1669
+ </div>
1670
+ <div class="toolbar">
1671
+ <button class="btn btn-sm filter-btn active" onclick="filterMailbox('', event)">All</button>
1672
+ <button class="btn btn-sm filter-btn" onclick="filterMailbox('inbox', event)">Inbox</button>
1673
+ <button class="btn btn-sm filter-btn" onclick="filterMailbox('outbox', event)">Outbox</button>
1674
+ </div>
1675
+ <div id="mailbox-list"></div>
1676
+ <div id="mail-detail" class="hidden p-md"></div>
1677
+ </div>
1678
+
1679
+ <!-- Messages Panel -->
1680
+ <div id="panel-messages" class="dev-panel hidden">
1681
+ <div class="dev-panel-header">
1682
+ <h2>Message Log</h2>
1683
+ <div class="flex gap-sm items-center">
1684
+ <input type="text" id="msg-search" class="input" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchMessages()">
1685
+ <button class="btn btn-sm" onclick="searchMessages()">Search</button>
1686
+ <button class="btn btn-sm" onclick="loadMessages()">All</button>
1687
+ <button class="btn btn-sm btn-danger" onclick="clearMessages()">Clear</button>
1688
+ </div>
1689
+ </div>
1690
+ <div id="messages-list"></div>
1691
+ <div id="messages-empty" class="empty">No messages logged</div>
1692
+ </div>
1693
+
1694
+ <!-- Database Panel -->
1695
+ <div id="panel-database" class="dev-panel hidden">
1696
+ <div class="dev-panel-header">
1697
+ <h2>Database</h2>
1698
+ <button class="btn btn-sm" onclick="loadTables()">Refresh</button>
1699
+ </div>
1700
+ <div class="flex gap-md p-md">
1701
+ <div class="flex-1">
1702
+ <div class="flex gap-sm items-center mb-sm">
1703
+ <select id="query-type" class="input">
1704
+ <option value="sql">SQL</option>
1705
+ <option value="graphql">GraphQL</option>
1706
+ </select>
1707
+ <button class="btn btn-sm btn-primary" onclick="runQuery()">Run</button>
1708
+ <span class="text-sm text-muted">Ctrl+Enter</span>
1709
+ </div>
1710
+ <textarea id="query-input" rows="4" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%"></textarea>
1711
+ <div id="query-error" class="hidden" style="color:var(--danger);font-size:0.75rem;margin-top:0.25rem"></div>
1712
+ </div>
1713
+ <div style="width:180px">
1714
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Tables</div>
1715
+ <div id="table-list" class="text-sm"></div>
1716
+ <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem">
1717
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Seed Data</div>
1718
+ <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem"><option value="">Pick table...</option></select>
1719
+ <div class="flex gap-sm items-center">
1720
+ <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px">
1721
+ <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
1722
+ </div>
1723
+ </div>
1724
+ </div>
1725
+ </div>
1726
+ <div id="query-results" style="overflow-x:auto"></div>
1727
+ </div>
1728
+
1729
+ <!-- Requests Panel -->
1730
+ <div id="panel-requests" class="dev-panel hidden">
1731
+ <div class="dev-panel-header">
1732
+ <h2>Request Inspector</h2>
1733
+ <div class="flex gap-sm">
1734
+ <button class="btn btn-sm" onclick="loadRequests()">Refresh</button>
1735
+ <button class="btn btn-sm btn-danger" onclick="clearRequests()">Clear</button>
1736
+ </div>
1737
+ </div>
1738
+ <div id="req-stats" class="toolbar text-sm text-muted"></div>
1739
+ <table>
1740
+ <thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Size</th></tr></thead>
1741
+ <tbody id="req-body"></tbody>
1742
+ </table>
1743
+ <div id="req-empty" class="empty hidden">No requests captured</div>
1744
+ </div>
1745
+
1746
+ <!-- Errors Panel -->
1747
+ <div id="panel-errors" class="dev-panel hidden">
1748
+ <div class="dev-panel-header">
1749
+ <h2>Error Tracker</h2>
1750
+ <div class="flex gap-sm">
1751
+ <button class="btn btn-sm" onclick="loadErrors()">Refresh</button>
1752
+ <button class="btn btn-sm btn-danger" onclick="clearResolvedErrors()">Clear Resolved</button>
1753
+ </div>
1754
+ </div>
1755
+ <div id="errors-list"></div>
1756
+ <div id="errors-empty" class="empty">No errors tracked</div>
1757
+ </div>
1758
+
1759
+ <!-- WebSocket Panel -->
1760
+ <div id="panel-websockets" class="dev-panel hidden">
1761
+ <div class="dev-panel-header">
1762
+ <h2>WebSocket Connections</h2>
1763
+ <button class="btn btn-sm" onclick="loadWebSockets()">Refresh</button>
1764
+ </div>
1765
+ <table>
1766
+ <thead><tr><th>ID</th><th>Path</th><th>IP</th><th>Connected</th><th>Status</th><th></th></tr></thead>
1767
+ <tbody id="ws-body"></tbody>
1768
+ </table>
1769
+ <div id="ws-empty" class="empty">No active connections</div>
1770
+ </div>
1771
+
1772
+ <!-- System Panel -->
1773
+ <div id="panel-system" class="dev-panel hidden">
1774
+ <div class="dev-panel-header">
1775
+ <h2>System Overview</h2>
1776
+ <button class="btn btn-sm" onclick="loadSystem()">Refresh</button>
1777
+ </div>
1778
+ <div id="sys-cards" class="sys-grid"></div>
1779
+ <div id="sys-extensions" class="hidden"></div>
1780
+ </div>
1781
+
1782
+ <!-- Tools Panel -->
1783
+ <div id="panel-tools" class="dev-panel hidden">
1784
+ <div class="dev-panel-header">
1785
+ <h2>Developer Tools</h2>
1786
+ </div>
1787
+ <div class="sys-grid">
1788
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('test')">
1789
+ <div class="label">Run Tests</div>
1790
+ <div style="font-size:0.8rem;margin-top:0.25rem">Execute the test suite</div>
1791
+ </div>
1792
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('routes')">
1793
+ <div class="label">List Routes</div>
1794
+ <div style="font-size:0.8rem;margin-top:0.25rem">Show all registered routes with auth status</div>
1795
+ </div>
1796
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('migrate')">
1797
+ <div class="label">Run Migrations</div>
1798
+ <div style="font-size:0.8rem;margin-top:0.25rem">Apply pending database migrations</div>
1799
+ </div>
1800
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('seed')">
1801
+ <div class="label">Run Seeders</div>
1802
+ <div style="font-size:0.8rem;margin-top:0.25rem">Execute seed scripts</div>
1803
+ </div>
1804
+ </div>
1805
+ <div id="tool-output" class="hidden" style="margin:1rem">
1806
+ <div class="dev-panel-header">
1807
+ <h2 id="tool-title">Output</h2>
1808
+ <button class="btn btn-sm" onclick="document.getElementById('tool-output').classList.add('hidden')">Close</button>
1809
+ </div>
1810
+ <pre id="tool-result" style="padding:1rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:0.75rem;font-family:var(--mono);max-height:400px;overflow:auto;white-space:pre-wrap"></pre>
1811
+ </div>
1812
+ </div>
1813
+
1814
+ <!-- Connections Panel -->
1815
+ <div id="panel-connections" class="dev-panel hidden">
1816
+ <div class="dev-panel-header">
1817
+ <h2>Connection Builder</h2>
1818
+ </div>
1819
+ <div class="p-md">
1820
+ <div class="flex gap-md" style="flex-wrap:wrap">
1821
+ <div style="flex:1;min-width:300px">
1822
+ <div class="mb-sm">
1823
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Driver</label>
1824
+ <select id="conn-driver" class="input" style="width:100%" onchange="connDriverChanged()">
1825
+ <option value="sqlite">SQLite</option>
1826
+ <option value="postgresql">PostgreSQL</option>
1827
+ <option value="mysql">MySQL</option>
1828
+ <option value="mssql">MSSQL</option>
1829
+ <option value="firebird">Firebird</option>
1830
+ </select>
1831
+ </div>
1832
+ <div class="mb-sm conn-server-field">
1833
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Host</label>
1834
+ <input type="text" id="conn-host" class="input" style="width:100%" value="localhost" placeholder="localhost" oninput="updateConnectionUrl()">
1835
+ </div>
1836
+ <div class="mb-sm conn-server-field">
1837
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Port</label>
1838
+ <input type="number" id="conn-port" class="input" style="width:100%" placeholder="5432" oninput="updateConnectionUrl()">
1839
+ </div>
1840
+ <div class="mb-sm">
1841
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Database</label>
1842
+ <input type="text" id="conn-database" class="input" style="width:100%" placeholder="mydb" oninput="updateConnectionUrl()">
1843
+ </div>
1844
+ <div class="mb-sm conn-server-field">
1845
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Username</label>
1846
+ <input type="text" id="conn-username" class="input" style="width:100%" placeholder="username">
1847
+ </div>
1848
+ <div class="mb-sm conn-server-field">
1849
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Password</label>
1850
+ <input type="password" id="conn-password" class="input" style="width:100%" placeholder="password">
1851
+ </div>
1852
+ <div class="mb-sm">
1853
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Connection URL</label>
1854
+ <input type="text" id="conn-url" class="input input-mono" style="width:100%" readonly>
1855
+ </div>
1856
+ <div class="flex gap-sm">
1857
+ <button class="btn btn-primary" onclick="testConnection()">Test Connection</button>
1858
+ <button class="btn btn-success" onclick="saveConnection()">Save to .env</button>
1859
+ </div>
1860
+ </div>
1861
+ <div style="width:300px">
1862
+ <div class="dev-panel" style="margin-bottom:1rem">
1863
+ <div class="dev-panel-header"><h2>Test Result</h2></div>
1864
+ <div id="conn-test-result" class="p-md text-sm text-muted">No test run yet</div>
1865
+ </div>
1866
+ <div class="dev-panel">
1867
+ <div class="dev-panel-header"><h2>Current .env Values</h2></div>
1868
+ <div id="conn-env-values" class="p-md text-sm text-muted">Loading...</div>
1869
+ </div>
1870
+ </div>
1871
+ </div>
1872
+ </div>
1873
+ </div>
1874
+
1875
+ <script>
1876
+ function connDriverChanged() {
1877
+ var driver = document.getElementById('conn-driver').value;
1878
+ var ports = {postgresql: 5432, mysql: 3306, mssql: 1433, firebird: 3050};
1879
+ var isSqlite = (driver === 'sqlite');
1880
+ document.getElementById('conn-port').value = ports[driver] || '';
1881
+ var fields = document.querySelectorAll('.conn-server-field');
1882
+ for (var i = 0; i < fields.length; i++) {
1883
+ fields[i].style.display = isSqlite ? 'none' : '';
1884
+ }
1885
+ updateConnectionUrl();
1886
+ }
1887
+ function updateConnectionUrl() {
1888
+ var driver = document.getElementById('conn-driver').value;
1889
+ var host = document.getElementById('conn-host').value || 'localhost';
1890
+ var port = document.getElementById('conn-port').value;
1891
+ var database = document.getElementById('conn-database').value;
1892
+ if (driver === 'sqlite') {
1893
+ document.getElementById('conn-url').value = 'sqlite:///' + database;
1894
+ } else {
1895
+ document.getElementById('conn-url').value = driver + '://' + host + ':' + port + '/' + database;
1896
+ }
1897
+ }
1898
+ function testConnection() {
1899
+ var url = document.getElementById('conn-url').value;
1900
+ var username = document.getElementById('conn-username').value;
1901
+ var password = document.getElementById('conn-password').value;
1902
+ var el = document.getElementById('conn-test-result');
1903
+ el.innerHTML = '<span class="text-muted">Testing...</span>';
1904
+ fetch('/__dev/api/connections/test', {
1905
+ method: 'POST',
1906
+ headers: {'Content-Type': 'application/json'},
1907
+ body: JSON.stringify({url: url, username: username, password: password})
1908
+ }).then(function(r){return r.json()}).then(function(data) {
1909
+ if (data.success) {
1910
+ el.innerHTML = '<div style="color:var(--success);font-weight:600;margin-bottom:0.5rem">&#10004; Connected</div>' +
1911
+ '<div class="text-sm">Version: ' + (data.version || 'N/A') + '</div>' +
1912
+ '<div class="text-sm">Tables: ' + (data.tables !== undefined ? data.tables : 'N/A') + '</div>';
1913
+ } else {
1914
+ el.innerHTML = '<div style="color:var(--danger);font-weight:600;margin-bottom:0.5rem">&#10008; Failed</div>' +
1915
+ '<div class="text-sm" style="color:var(--danger)">' + (data.error || 'Unknown error') + '</div>';
1916
+ }
1917
+ }).catch(function(e) {
1918
+ el.innerHTML = '<div style="color:var(--danger)">Error: ' + e.message + '</div>';
1919
+ });
1920
+ }
1921
+ function saveConnection() {
1922
+ var url = document.getElementById('conn-url').value;
1923
+ var username = document.getElementById('conn-username').value;
1924
+ var password = document.getElementById('conn-password').value;
1925
+ if (!url) { alert('Please build a connection URL first'); return; }
1926
+ fetch('/__dev/api/connections/save', {
1927
+ method: 'POST',
1928
+ headers: {'Content-Type': 'application/json'},
1929
+ body: JSON.stringify({url: url, username: username, password: password})
1930
+ }).then(function(r){return r.json()}).then(function(data) {
1931
+ if (data.success) {
1932
+ alert('Connection saved to .env');
1933
+ loadConnectionEnv();
1934
+ } else {
1935
+ alert('Save failed: ' + (data.error || 'Unknown error'));
1936
+ }
1937
+ }).catch(function(e) { alert('Error: ' + e.message); });
1938
+ }
1939
+ function loadConnectionEnv() {
1940
+ fetch('/__dev/api/connections').then(function(r){return r.json()}).then(function(data) {
1941
+ var el = document.getElementById('conn-env-values');
1942
+ el.innerHTML = '<div class="mb-sm"><span class="text-muted">DATABASE_URL:</span> <code>' + (data.url || '<em>not set</em>') + '</code></div>' +
1943
+ '<div class="mb-sm"><span class="text-muted">DATABASE_USERNAME:</span> <code>' + (data.username || '<em>not set</em>') + '</code></div>' +
1944
+ '<div><span class="text-muted">DATABASE_PASSWORD:</span> <code>' + (data.password || '<em>not set</em>') + '</code></div>';
1945
+ }).catch(function() {
1946
+ document.getElementById('conn-env-values').innerHTML = '<span class="text-muted">Could not load .env values</span>';
1947
+ });
1948
+ }
1949
+ document.addEventListener('DOMContentLoaded', function() {
1950
+ var connTab = document.querySelector('[onclick*="connections"]');
1951
+ if (connTab) {
1952
+ connTab.addEventListener('click', function() { loadConnectionEnv(); }, {once: true});
1953
+ }
1954
+ });
1955
+ </script>
1956
+
1957
+ <!-- Chat Panel (Tina4) -->
1958
+ <div id="panel-chat" class="dev-panel hidden">
1959
+ <div class="dev-panel-header">
1960
+ <h2>Tina4</h2>
1961
+ <div class="flex gap-sm items-center">
1962
+ <select id="ai-provider" class="input" style="width:120px">
1963
+ <option value="anthropic">Claude</option>
1964
+ <option value="openai">OpenAI</option>
1965
+ </select>
1966
+ <input type="password" id="ai-key" class="input" placeholder="Paste API key..." style="width:250px">
1967
+ <button class="btn btn-sm btn-primary" onclick="setAiKey()">Set Key</button>
1968
+ <span class="text-sm text-muted" id="ai-status">No key set</span>
1969
+ </div>
1970
+ </div>
1971
+ <div class="chat-container">
1972
+ <div class="chat-messages" id="chat-messages">
1973
+ <div class="chat-msg chat-bot">Hi! I'm Tina4. Ask me about routes, ORM, database, queues, templates, auth, or any Tina4 feature.</div>
1974
+ </div>
1975
+ <div class="chat-input-row">
1976
+ <input type="text" id="chat-input" class="input" placeholder="Ask Tina4..." onkeydown="if(event.key==='Enter')sendChat()">
1977
+ <button class="btn btn-primary" onclick="sendChat()">Send</button>
1978
+ </div>
1979
+ </div>
1980
+ </div>
1981
+
1982
+ </div>
1983
+
1984
+ <script src="/__dev/js/tina4-dev-admin.min.js"></script>
1985
+ <script>
1986
+ // Self-diagnostic — detect if the external JS failed to load
1987
+ (function() {
1988
+ if (typeof showTab !== 'function') {
1989
+ var banner = document.createElement('div');
1990
+ banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#ef4444;color:#fff;padding:0.75rem 1rem;font-family:system-ui;font-size:0.85rem;text-align:center';
1991
+ banner.innerHTML = '<strong>Dev Admin Error:</strong> tina4-dev-admin.min.js failed to load.';
1992
+ document.body.insertBefore(banner, document.body.firstChild);
1993
+ }
1994
+ })();
1995
+ </script>
1996
+ </body>
1997
+ </html>`;
1998
+ }
1999
+
2000
+ // ---------------------------------------------------------------------------
2001
+ // Overlay script — floating Tina4 button
2002
+ // ---------------------------------------------------------------------------
2003
+
2004
+ function renderToolbarHtml(ctx: {
2005
+ version: string;
2006
+ method: string;
2007
+ path: string;
2008
+ matchedPattern: string;
2009
+ requestId: string;
2010
+ routeCount: number;
2011
+ }): string {
2012
+ const nodeVersion = process.version;
2013
+ return `<div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
2014
+ <span style="color:#2e7d32;font-weight:bold;">Tina4 v${ctx.version}</span>
2015
+ <span style="color:#4caf50;">${ctx.method}</span>
2016
+ <span>${ctx.path}</span>
2017
+ <span style="color:#666;">&rarr; ${ctx.matchedPattern}</span>
2018
+ <span style="color:#ffeb3b;">req:${ctx.requestId}</span>
2019
+ <span style="color:#90caf9;">${ctx.routeCount} routes</span>
2020
+ <span style="color:#888;">Node.js ${nodeVersion}</span>
2021
+ <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;bottom:2rem;right:1rem;width:min(90vw,1200px);height:min(80vh,700px);z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #2e7d32;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
2022
+ <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
2023
+ </div>`;
2024
+ }