webspresso 0.0.97 → 0.0.98

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 (100) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
@@ -16,33 +16,141 @@ const { purgeAuditLogs } = require('./purge');
16
16
  * @param {string} [options.tableName='audit_logs']
17
17
  * @param {boolean} [options.includeDefaultPage=true] - Register Mithril list page + menu
18
18
  * @param {string} [options.apiPrefix='/audit-logs'] - GET list under admin API
19
+ * @param {Object|boolean} [options.retention] - Retention policy configuration (set false to disable)
20
+ * @param {number} [options.retention.days=90] - Days to retain audit logs
21
+ * @param {number} [options.retention.intervalMs=86400000] - Auto-cleanup timer frequency (24h)
22
+ * @param {boolean} [options.retention.runOnStart=true] - Run initial purge shortly after startup
23
+ * @param {number} [options.retentionDays=90] - Shorthand for retention.days
24
+ * @param {number} [options.cleanupIntervalMs=86400000] - Shorthand for retention.intervalMs
25
+ * @param {boolean} [options.autoCleanup=true] - Enable automatic background cleanup
26
+ * @param {boolean} [options.cleanupOnStart=true] - Run initial purge on start
27
+ * @param {number} [options.cleanupStartDelayMs] - Delay in ms before start purge
19
28
  */
20
29
  function auditLogPlugin(options = {}) {
21
30
  const {
22
31
  db,
32
+ title,
33
+ adminTitle,
34
+ pageTitle: customPageTitle,
23
35
  adminPath: adminPathOpt,
24
36
  tableName = 'audit_logs',
25
37
  includeDefaultPage = true,
26
38
  apiPrefix = '/audit-logs',
27
39
  } = options;
28
40
 
41
+ const pageTitle = title || adminTitle || customPageTitle || 'Audit log';
42
+
29
43
  if (!db) {
30
44
  throw new Error('audit-log plugin requires a database instance. Pass `db` in options.');
31
45
  }
32
46
 
33
47
  const knex = db.knex || db;
34
48
 
49
+ // Retention configuration
50
+ const retentionOption = options.retention;
51
+ const isRetentionDisabled =
52
+ retentionOption === false ||
53
+ (typeof retentionOption === 'object' && retentionOption.enabled === false) ||
54
+ options.retentionDays === null ||
55
+ options.retentionDays === 0;
56
+
57
+ const autoCleanupEnabled =
58
+ !isRetentionDisabled &&
59
+ options.autoCleanup !== false &&
60
+ (typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false);
61
+
62
+ const retentionConfig = {
63
+ enabled: !isRetentionDisabled,
64
+ autoCleanup: autoCleanupEnabled,
65
+ days: (typeof options.retentionDays === 'number' && options.retentionDays > 0)
66
+ ? options.retentionDays
67
+ : (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 90),
68
+ intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
69
+ ? options.cleanupIntervalMs
70
+ : (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
71
+ runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
72
+ ? !!retentionOption.runOnStart
73
+ : (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : true),
74
+ };
75
+
76
+ let cleanupInterval = null;
77
+ let cleanupStartTimeout = null;
78
+
79
+ async function runCleanup() {
80
+ try {
81
+ return await purgeAuditLogs(knex, {
82
+ tableName,
83
+ days: retentionConfig.days,
84
+ });
85
+ } catch (err) {
86
+ if (process.env.NODE_ENV !== 'test') {
87
+ console.warn('[audit-log] Failed to run retention cleanup:', err.message);
88
+ }
89
+ }
90
+ }
91
+
92
+ function startCleanup(customIntervalMs) {
93
+ stopCleanup();
94
+ if (!retentionConfig.enabled) return;
95
+ if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
96
+ retentionConfig.intervalMs = customIntervalMs;
97
+ }
98
+
99
+ if (retentionConfig.runOnStart) {
100
+ const delayMs = typeof options.cleanupStartDelayMs === 'number'
101
+ ? options.cleanupStartDelayMs
102
+ : (process.env.NODE_ENV === 'test' ? 50 : 5000);
103
+ cleanupStartTimeout = setTimeout(() => {
104
+ runCleanup();
105
+ }, delayMs);
106
+ if (cleanupStartTimeout && cleanupStartTimeout.unref) {
107
+ cleanupStartTimeout.unref();
108
+ }
109
+ }
110
+
111
+ cleanupInterval = setInterval(() => {
112
+ runCleanup();
113
+ }, retentionConfig.intervalMs);
114
+ if (cleanupInterval && cleanupInterval.unref) {
115
+ cleanupInterval.unref();
116
+ }
117
+ }
118
+
119
+ function stopCleanup() {
120
+ if (cleanupStartTimeout) {
121
+ clearTimeout(cleanupStartTimeout);
122
+ cleanupStartTimeout = null;
123
+ }
124
+ if (cleanupInterval) {
125
+ clearInterval(cleanupInterval);
126
+ cleanupInterval = null;
127
+ }
128
+ }
129
+
35
130
  const handlers = createAuditLogHandlers({ knex, tableName });
36
131
 
37
132
  return {
38
133
  name: 'audit-log',
39
134
  version: '1.0.0',
40
- description: 'Audit trail for admin panel model CRUD API',
135
+ description: 'Audit trail for admin panel model CRUD API with retention cleanup',
41
136
  dependencies: { 'admin-panel': '*' },
42
137
 
43
138
  api: {
139
+ purge(opts = {}) {
140
+ return purgeAuditLogs(knex, {
141
+ tableName,
142
+ days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
143
+ olderThan: opts.olderThan,
144
+ ...opts,
145
+ });
146
+ },
44
147
  purgeAuditLogs: (kx, opts = {}) =>
45
- purgeAuditLogs(kx || knex, { tableName: opts.tableName || tableName, olderThan: opts.olderThan }),
148
+ purgeAuditLogs(kx || knex, { tableName: opts.tableName || tableName, olderThan: opts.olderThan, days: opts.days, retentionDays: opts.retentionDays }),
149
+ startCleanup,
150
+ stopCleanup,
151
+ getRetentionConfig() {
152
+ return { ...retentionConfig };
153
+ },
46
154
  queryLogs: (filters) => handlers.queryLogs(filters),
47
155
  getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
48
156
  },
@@ -50,6 +158,22 @@ function auditLogPlugin(options = {}) {
50
158
  register(ctx) {
51
159
  const adminPath = adminPathOpt || '/_admin';
52
160
  ctx.app.use(createAuditMiddleware({ knex, adminPath, tableName }));
161
+
162
+ if (retentionConfig.enabled && autoCleanupEnabled) {
163
+ startCleanup();
164
+ }
165
+
166
+ return () => {
167
+ stopCleanup();
168
+ };
169
+ },
170
+
171
+ dispose() {
172
+ stopCleanup();
173
+ },
174
+
175
+ shutdown() {
176
+ stopCleanup();
53
177
  },
54
178
 
55
179
  onRoutesReady(ctx) {
@@ -61,11 +185,12 @@ function auditLogPlugin(options = {}) {
61
185
 
62
186
  adminApi.registerModule({
63
187
  id: 'audit-log',
188
+ title: pageTitle,
64
189
 
65
190
  pages: includeDefaultPage
66
191
  ? [{
67
192
  id: 'audit-log',
68
- title: 'Audit log',
193
+ title: pageTitle,
69
194
  path: '/audit-log',
70
195
  icon: 'database',
71
196
  component: generateAuditLogComponent({ apiPrefix }),
@@ -86,6 +211,7 @@ function auditLogPlugin(options = {}) {
86
211
  prefix: apiPrefix,
87
212
  routes: [
88
213
  { method: 'get', path: '', handler: handlers.listHandler },
214
+ { method: 'post', path: '/purge', handler: handlers.purgeHandler },
89
215
  ],
90
216
  },
91
217
  });
@@ -3,24 +3,74 @@
3
3
  * @module plugins/audit-log/purge
4
4
  */
5
5
 
6
+ 'use strict';
7
+
6
8
  /**
7
9
  * @param {import('knex').Knex} knex
8
- * @param {Object} options
10
+ * @param {Object} [options={}]
9
11
  * @param {string} [options.tableName='audit_logs']
10
- * @param {Date|string} options.olderThan - Rows with created_at strictly before this are removed
11
- * @returns {Promise<number>} Deleted row count (driver-dependent; 0 if unknown)
12
+ * @param {Date|string|number} [options.olderThan] - Rows with created_at strictly before this are removed
13
+ * @param {number} [options.days] - Number of days of audit logs to keep
14
+ * @param {number} [options.retentionDays=90] - Alias for days
15
+ * @returns {Promise<number>} Deleted row count
12
16
  */
13
- async function purgeAuditLogs(knex, options) {
17
+ async function purgeAuditLogs(knex, options = {}) {
18
+ if (!knex) {
19
+ throw new Error('purgeAuditLogs requires a database (knex) instance');
20
+ }
21
+
14
22
  const tableName = options.tableName || 'audit_logs';
15
- const olderThan = options.olderThan instanceof Date
16
- ? options.olderThan
17
- : new Date(options.olderThan);
18
23
 
19
- if (Number.isNaN(olderThan.getTime())) {
20
- throw new Error('purgeAuditLogs: invalid olderThan date');
24
+ let olderThan;
25
+ if (options.olderThan !== undefined) {
26
+ olderThan = options.olderThan instanceof Date
27
+ ? options.olderThan
28
+ : new Date(options.olderThan);
29
+
30
+ if (Number.isNaN(olderThan.getTime())) {
31
+ throw new Error('purgeAuditLogs: invalid olderThan date');
32
+ }
33
+ } else {
34
+ const rawDays = options.days !== undefined ? options.days : (options.retentionDays !== undefined ? options.retentionDays : 90);
35
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
36
+ if (Number.isNaN(days) || days <= 0) {
37
+ throw new Error('purgeAuditLogs: days must be a positive number');
38
+ }
39
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
40
+ }
41
+
42
+ let hasTable = true;
43
+ if (knex.schema && typeof knex.schema.hasTable === 'function') {
44
+ try {
45
+ hasTable = await knex.schema.hasTable(tableName);
46
+ } catch {
47
+ hasTable = false;
48
+ }
49
+ }
50
+
51
+ if (!hasTable) {
52
+ return 0;
53
+ }
54
+
55
+ const client = knex?.client?.config?.client;
56
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
57
+
58
+ let query = knex(tableName);
59
+ if (isSqlite) {
60
+ const iso = olderThan.toISOString();
61
+ const cutoffMs = olderThan.getTime();
62
+ query = query.where((builder) => {
63
+ builder.whereRaw(
64
+ "((typeof(created_at) = 'integer' and created_at < ?) or (typeof(created_at) = 'text' and created_at < ?))",
65
+ [cutoffMs, iso]
66
+ );
67
+ });
68
+ } else {
69
+ query = query.where('created_at', '<', olderThan);
21
70
  }
22
71
 
23
- return knex(tableName).where('created_at', '<', olderThan).delete();
72
+ const deleted = await query.delete();
73
+ return Number(deleted) || 0;
24
74
  }
25
75
 
26
76
  module.exports = {
@@ -10,6 +10,8 @@ const state = {
10
10
  filter: 'all',
11
11
  search: '',
12
12
  routes: window.__DASHBOARD_DATA__.routes || [],
13
+ models: window.__DASHBOARD_DATA__.models || [],
14
+ services: window.__DASHBOARD_DATA__.services || [],
13
15
  plugins: window.__DASHBOARD_DATA__.plugins || [],
14
16
  config: window.__DASHBOARD_DATA__.config || {}
15
17
  };
@@ -29,18 +31,27 @@ const Icons = {
29
31
  // Components
30
32
  const Header = {
31
33
  view: () => m('.header', [
32
- m('h1', [
33
- Icons.logo(),
34
- 'Webspresso Dashboard'
34
+ m('div', [
35
+ m('h1', [
36
+ Icons.logo(),
37
+ 'Webspresso Dashboard'
38
+ ]),
39
+ m('span.dev-badge', 'development')
35
40
  ]),
36
- m('span.dev-badge', 'development')
41
+ m('.header-actions', [
42
+ m('a.action-link', { href: '/_admin', target: '_blank', rel: 'noopener' }, '⚙️ Admin Panel ↗'),
43
+ m('a.action-link', { href: '/_swagger', target: '_blank', rel: 'noopener' }, '📖 API Docs ↗'),
44
+ m('a.action-link', { href: '/_schema', target: '_blank', rel: 'noopener' }, '🗂️ Schema Explorer ↗')
45
+ ])
37
46
  ])
38
47
  };
39
48
 
40
49
  const Tabs = {
41
50
  view: () => m('.tabs', [
42
- ['routes', 'Routes'],
43
- ['plugins', 'Plugins'],
51
+ ['routes', 'Routes (' + state.routes.length + ')'],
52
+ ['models', 'Models (' + state.models.length + ')'],
53
+ ['services', 'Services (' + state.services.length + ')'],
54
+ ['plugins', 'Plugins (' + state.plugins.length + ')'],
44
55
  ['config', 'Config']
45
56
  ].map(([id, label]) =>
46
57
  m('button.tab', {
@@ -72,32 +83,36 @@ const FilterBar = {
72
83
  };
73
84
 
74
85
  const MethodBadge = {
75
- view: (vnode) => m('span.method-badge.method-' + vnode.attrs.method,
76
- vnode.attrs.method.toUpperCase()
86
+ view: (vnode) => m('span.method-badge.method-' + (vnode.attrs.method || 'get').toLowerCase(),
87
+ (vnode.attrs.method || 'GET').toUpperCase()
77
88
  )
78
89
  };
79
90
 
80
91
  const TypeBadge = {
81
- view: (vnode) => m('span.type-badge.type-' + vnode.attrs.type,
82
- vnode.attrs.type.toUpperCase()
83
- )
92
+ view: (vnode) => {
93
+ const rawType = vnode.attrs.type || 'page';
94
+ const label = rawType === 'page' ? 'SSR' : rawType.toUpperCase();
95
+ return m('span.type-badge.type-' + (rawType === 'page' ? 'ssr' : rawType), label);
96
+ }
84
97
  };
85
98
 
86
99
  const RoutesTable = {
87
100
  view: () => {
88
101
  let routes = state.routes;
89
102
 
90
- // Apply filter
91
- if (state.filter !== 'all') {
92
- routes = routes.filter(r => r.type === state.filter);
103
+ // Apply filter: SSR matches both 'ssr' and 'page'
104
+ if (state.filter === 'ssr') {
105
+ routes = routes.filter(r => r.type === 'ssr' || r.type === 'page');
106
+ } else if (state.filter === 'api') {
107
+ routes = routes.filter(r => r.type === 'api');
93
108
  }
94
109
 
95
- // Apply search
110
+ // Apply search: checks path or pattern or file
96
111
  if (state.search) {
97
112
  const search = state.search.toLowerCase();
98
113
  routes = routes.filter(r =>
99
- r.pattern.toLowerCase().includes(search) ||
100
- r.file.toLowerCase().includes(search)
114
+ ((r.path || r.pattern || '')).toLowerCase().includes(search) ||
115
+ ((r.file || '')).toLowerCase().includes(search)
101
116
  );
102
117
  }
103
118
 
@@ -121,17 +136,18 @@ const RoutesTable = {
121
136
  ])
122
137
  ),
123
138
  m('tbody',
124
- routes.map(route =>
125
- m('tr', [
139
+ routes.map(route => {
140
+ const displayPath = route.path || route.pattern || '/';
141
+ return m('tr', [
126
142
  m('td', m(MethodBadge, { method: route.method })),
127
143
  m('td', [
128
- m('span.code', route.pattern),
144
+ m('span.code', displayPath),
129
145
  route.isDynamic ? m('span.dynamic-indicator', '⚡ dynamic') : null
130
146
  ]),
131
- m('td', m('span.file-path', route.file)),
147
+ m('td', m('span.file-path', route.file || 'custom')),
132
148
  m('td', m(TypeBadge, { type: route.type }))
133
- ])
134
- )
149
+ ]);
150
+ })
135
151
  )
136
152
  ])
137
153
  );
@@ -140,7 +156,7 @@ const RoutesTable = {
140
156
 
141
157
  const StatsGrid = {
142
158
  view: () => {
143
- const ssrCount = state.routes.filter(r => r.type === 'ssr').length;
159
+ const ssrCount = state.routes.filter(r => r.type === 'ssr' || r.type === 'page').length;
144
160
  const apiCount = state.routes.filter(r => r.type === 'api').length;
145
161
  const dynamicCount = state.routes.filter(r => r.isDynamic).length;
146
162
 
@@ -173,6 +189,116 @@ const RoutesView = {
173
189
  ])
174
190
  };
175
191
 
192
+ const ModelsView = {
193
+ view: () => {
194
+ const models = state.models || [];
195
+ if (models.length === 0) {
196
+ return m('.table-container',
197
+ m('.empty-state', [
198
+ Icons.empty(),
199
+ m('p', 'No ORM models registered in core/orm')
200
+ ])
201
+ );
202
+ }
203
+
204
+ return m('div', [
205
+ m('.stats-grid', [
206
+ m('.card', [
207
+ m('.card-title', 'Total Models'),
208
+ m('.card-value', models.length)
209
+ ]),
210
+ m('.card', [
211
+ m('.card-title', 'Cached Models'),
212
+ m('.card-value', models.filter(m => Boolean(m.cache)).length)
213
+ ]),
214
+ m('.card', [
215
+ m('.card-title', 'Soft-Delete Enabled'),
216
+ m('.card-value', models.filter(m => Boolean(m.softDelete)).length)
217
+ ])
218
+ ]),
219
+ m('.table-container',
220
+ m('table', [
221
+ m('thead',
222
+ m('tr', [
223
+ m('th', 'Model'),
224
+ m('th', 'Table'),
225
+ m('th', 'Primary Key'),
226
+ m('th', 'Columns'),
227
+ m('th', 'Soft Delete'),
228
+ m('th', 'Cache')
229
+ ])
230
+ ),
231
+ m('tbody',
232
+ models.map(mod =>
233
+ m('tr', [
234
+ m('td', m('strong', mod.name)),
235
+ m('td', m('span.code-badge', mod.table)),
236
+ m('td', m('span.badge.badge-blue', mod.primaryKey || 'id')),
237
+ m('td', (mod.columnCount || 0) + ' columns'),
238
+ m('td', mod.softDelete ? m('span.badge.badge-green', 'Enabled') : m('span.badge.badge-gray', 'Disabled')),
239
+ m('td', mod.cache ? m('span.badge.badge-purple', String(mod.cache)) : m('span.badge.badge-gray', 'None'))
240
+ ])
241
+ )
242
+ )
243
+ ])
244
+ )
245
+ ]);
246
+ }
247
+ };
248
+
249
+ const ServicesView = {
250
+ view: () => {
251
+ const services = state.services || [];
252
+ if (services.length === 0) {
253
+ return m('.table-container',
254
+ m('.empty-state', [
255
+ Icons.empty(),
256
+ m('p', 'No services registered in services/ or ServiceRegistry')
257
+ ])
258
+ );
259
+ }
260
+
261
+ return m('div', [
262
+ m('.stats-grid', [
263
+ m('.card', [
264
+ m('.card-title', 'Total Services'),
265
+ m('.card-value', services.length)
266
+ ]),
267
+ m('.card', [
268
+ m('.card-title', 'ACID Transactions'),
269
+ m('.card-value', services.filter(s => s.transaction).length)
270
+ ]),
271
+ m('.card', [
272
+ m('.card-title', 'Auth Protected'),
273
+ m('.card-value', services.filter(s => s.auth).length)
274
+ ])
275
+ ]),
276
+ m('.table-container',
277
+ m('table', [
278
+ m('thead',
279
+ m('tr', [
280
+ m('th', 'Service Name'),
281
+ m('th', 'Transaction (ACID)'),
282
+ m('th', 'Auth Guard'),
283
+ m('th', 'Schema Validation')
284
+ ])
285
+ ),
286
+ m('tbody',
287
+ services.map(srv =>
288
+ m('tr', [
289
+ m('td', m('span.code-badge', srv.name)),
290
+ m('td', srv.transaction ? m('span.badge.badge-green', 'Active') : m('span.badge.badge-gray', 'No')),
291
+ m('td', srv.auth ? m('span.badge.badge-orange', String(srv.auth)) : m('span.badge.badge-gray', 'Public')),
292
+ m('td', srv.hasSchema ? m('span.badge.badge-blue', 'Zod Schema') : m('span.badge.badge-gray', 'None'))
293
+ ])
294
+ )
295
+ )
296
+ ])
297
+ )
298
+ ]);
299
+ }
300
+ };
301
+
176
302
  const PluginsView = {
177
303
  view: () => {
178
304
  if (state.plugins.length === 0) {
@@ -254,6 +380,8 @@ const App = {
254
380
  m(Header),
255
381
  m(Tabs),
256
382
  state.activeTab === 'routes' ? m(RoutesView) : null,
383
+ state.activeTab === 'models' ? m(ModelsView) : null,
384
+ state.activeTab === 'services' ? m(ServicesView) : null,
257
385
  state.activeTab === 'plugins' ? m(PluginsView) : null,
258
386
  state.activeTab === 'config' ? m(ConfigView) : null
259
387
  ])
@@ -262,6 +390,3 @@ const App = {
262
390
  // Mount app
263
391
  m.mount(document.getElementById('app'), App);
264
392
  `;
265
-
266
-
267
-