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
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Relations Graph & ER Diagram Utilities for Schema Explorer
3
+ * Analyzes ORM models, builds bidirectional relationship links, and generates Mermaid ER diagrams.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Extracts and unifies all relationships and foreign keys across all models
10
+ * @param {Array<Object>} models - Serialized models list
11
+ * @returns {Array<Object>} List of relationship edge descriptors
12
+ */
13
+ function extractAllRelationships(models = []) {
14
+ const modelByName = new Map();
15
+ const modelByTable = new Map();
16
+
17
+ for (const m of models) {
18
+ if (m && m.name) {
19
+ modelByName.set(m.name, m);
20
+ }
21
+ if (m && m.table) {
22
+ modelByTable.set(m.table, m);
23
+ }
24
+ }
25
+
26
+ const relationships = [];
27
+ const seenKeys = new Set();
28
+
29
+ for (const model of models) {
30
+ if (!model) continue;
31
+
32
+ // 1. Process explicit ORM relations
33
+ const relations = model.relations || [];
34
+ for (const rel of relations) {
35
+ if (!rel || !rel.name) continue;
36
+
37
+ let targetModelName = rel.relatedModel || null;
38
+ let targetTableName = rel.relatedTable || null;
39
+
40
+ if (targetModelName && modelByName.has(targetModelName)) {
41
+ targetTableName = targetTableName || modelByName.get(targetModelName).table;
42
+ } else if (targetTableName && modelByTable.has(targetTableName)) {
43
+ targetModelName = targetModelName || modelByTable.get(targetTableName).name;
44
+ }
45
+
46
+ const key = `${model.name}:${rel.name}:${targetModelName || targetTableName}:${rel.foreignKey || ''}`;
47
+ if (seenKeys.has(key)) continue;
48
+ seenKeys.add(key);
49
+
50
+ relationships.push({
51
+ id: `rel-${model.name}-${rel.name}-${targetModelName || targetTableName || 'unknown'}`,
52
+ sourceModel: model.name,
53
+ sourceTable: model.table,
54
+ name: rel.name,
55
+ type: rel.type || 'belongsTo',
56
+ targetModel: targetModelName,
57
+ targetTable: targetTableName,
58
+ foreignKey: rel.foreignKey || null,
59
+ localKey: rel.localKey || 'id',
60
+ pivotTable: rel.pivotTable || null,
61
+ pivotForeignKey: rel.pivotForeignKey || null,
62
+ pivotRelatedForeignKey: rel.pivotRelatedForeignKey || null,
63
+ inferred: false,
64
+ });
65
+ }
66
+
67
+ // 2. Process column references (foreign keys defined directly on columns)
68
+ const columns = model.columns || [];
69
+ for (const col of columns) {
70
+ if (!col || !col.references) continue;
71
+
72
+ const targetTableName = col.references;
73
+ const targetModel = modelByTable.get(targetTableName);
74
+ const targetModelName = targetModel ? targetModel.name : targetTableName;
75
+
76
+ // Check if an explicit relation already covers this column
77
+ const alreadyCovered = relationships.some(
78
+ r => r.sourceModel === model.name && r.foreignKey === col.name
79
+ );
80
+
81
+ if (!alreadyCovered) {
82
+ const key = `${model.name}:col_fk:${targetTableName}:${col.name}`;
83
+ if (!seenKeys.has(key)) {
84
+ seenKeys.add(key);
85
+ relationships.push({
86
+ id: `col-fk-${model.name}-${col.name}-${targetTableName}`,
87
+ sourceModel: model.name,
88
+ sourceTable: model.table,
89
+ name: col.name.replace(/_id$/i, ''),
90
+ type: 'belongsTo',
91
+ targetModel: targetModelName,
92
+ targetTable: targetTableName,
93
+ foreignKey: col.name,
94
+ localKey: col.referenceColumn || 'id',
95
+ inferred: true,
96
+ });
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ return relationships;
103
+ }
104
+
105
+ /**
106
+ * Enriches model objects with incomingRelations, outgoingRelations, and connectedModels
107
+ * @param {Array<Object>} models - Serialized models list
108
+ * @param {Array<Object>} [relationships] - Precomputed relationships
109
+ * @returns {Array<Object>} Enriched models list
110
+ */
111
+ function enrichModelsWithGraph(models = [], relationships = null) {
112
+ const rels = relationships || extractAllRelationships(models);
113
+
114
+ return models.map(model => {
115
+ const outgoing = rels.filter(r => r.sourceModel === model.name);
116
+ const incoming = rels.filter(r => r.targetModel === model.name);
117
+
118
+ const connectedSet = new Set();
119
+ for (const r of outgoing) {
120
+ if (r.targetModel && r.targetModel !== model.name) connectedSet.add(r.targetModel);
121
+ }
122
+ for (const r of incoming) {
123
+ if (r.sourceModel && r.sourceModel !== model.name) connectedSet.add(r.sourceModel);
124
+ }
125
+
126
+ return {
127
+ ...model,
128
+ outgoingRelations: outgoing,
129
+ incomingRelations: incoming,
130
+ connectedModels: Array.from(connectedSet),
131
+ };
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Generates valid Mermaid ER diagram syntax from models and relationships
137
+ * @param {Array<Object>} models - Serialized models
138
+ * @param {Array<Object>} [relationships] - Precomputed relationships
139
+ * @returns {string} Mermaid erDiagram code
140
+ */
141
+ function generateMermaidErDiagram(models = [], relationships = null) {
142
+ const rels = relationships || extractAllRelationships(models);
143
+ const lines = ['erDiagram'];
144
+
145
+ // 1. Entities & Attributes
146
+ for (const m of models) {
147
+ if (!m || !m.name) continue;
148
+ const safeModelName = m.name.replace(/[^a-zA-Z0-9_]/g, '_');
149
+ lines.push(` ${safeModelName} {`);
150
+
151
+ const cols = m.columns || [];
152
+ for (const col of cols) {
153
+ const colName = (col.name || 'unnamed').replace(/[^a-zA-Z0-9_]/g, '_');
154
+ const colType = (col.type || 'string').replace(/[^a-zA-Z0-9_]/g, '_');
155
+
156
+ let marker = '';
157
+ if (col.primary) {
158
+ marker = ' PK';
159
+ } else if (col.references || rels.some(r => r.sourceModel === m.name && r.foreignKey === col.name)) {
160
+ marker = ' FK';
161
+ } else if (col.unique) {
162
+ marker = ' UK';
163
+ }
164
+
165
+ lines.push(` ${colType} ${colName}${marker}`);
166
+ }
167
+
168
+ lines.push(' }');
169
+ }
170
+
171
+ // 2. Relations
172
+ const seenRelPairs = new Set();
173
+ for (const rel of rels) {
174
+ if (!rel.sourceModel || !rel.targetModel) continue;
175
+
176
+ const source = rel.sourceModel.replace(/[^a-zA-Z0-9_]/g, '_');
177
+ const target = rel.targetModel.replace(/[^a-zA-Z0-9_]/g, '_');
178
+ const pairKey = `${source}:${rel.type}:${target}:${rel.foreignKey || ''}`;
179
+
180
+ if (seenRelPairs.has(pairKey)) continue;
181
+ seenRelPairs.add(pairKey);
182
+
183
+ let symbol = '}o--||'; // default belongsTo
184
+ if (rel.type === 'hasMany') {
185
+ symbol = '||--o{';
186
+ } else if (rel.type === 'hasOne') {
187
+ symbol = '||--||';
188
+ } else if (rel.type === 'manyToMany') {
189
+ symbol = '}o--o{';
190
+ } else if (rel.type === 'belongsTo') {
191
+ symbol = '}o--||';
192
+ }
193
+
194
+ const label = (rel.name || rel.foreignKey || rel.type || 'relates').replace(/"/g, "'");
195
+ lines.push(` ${source} ${symbol} ${target} : "${label}"`);
196
+ }
197
+
198
+ return lines.join('\n');
199
+ }
200
+
201
+ module.exports = {
202
+ extractAllRelationships,
203
+ enrichModelsWithGraph,
204
+ generateMermaidErDiagram,
205
+ };
@@ -1,23 +1,41 @@
1
1
  /**
2
2
  * Schema Explorer Plugin
3
- * Exposes ORM model metadata via API endpoint
4
- * Useful for documentation, admin tools, and frontend code generation
3
+ * Exposes ORM model metadata via API endpoint and interactive Admin Panel SPA page.
4
+ * Useful for documentation, database architecture visualization, ER diagrams, and admin tools.
5
5
  */
6
6
 
7
+ 'use strict';
8
+
7
9
  const { getAllModels, getModel } = require('../core/orm/model');
8
10
  const { getColumnMeta } = require('../core/orm/schema-helpers');
9
11
  const { generateOrmOpenApiSchemas } = require('../core/openapi/orm-components');
12
+ const {
13
+ extractAllRelationships,
14
+ enrichModelsWithGraph,
15
+ generateMermaidErDiagram,
16
+ } = require('./schema-explorer/relations-graph');
17
+ const {
18
+ generateSchemaExplorerAdminComponent,
19
+ } = require('./schema-explorer/admin-component');
10
20
 
11
21
  /**
12
22
  * Create Schema Explorer plugin
13
- * @param {Object} options - Plugin options
14
- * @param {string} [options.path='/_schema'] - API endpoint path
15
- * @param {boolean} [options.enabled] - Force enable/disable (default: auto based on NODE_ENV)
23
+ * @param {Object} [options] - Plugin options
24
+ * @param {string} [options.path='/_schema'] - Raw API endpoint path
25
+ * @param {boolean} [options.enabled] - Force enable/disable raw API (default: auto based on NODE_ENV)
16
26
  * @param {string[]} [options.exclude=[]] - Model names to exclude
17
27
  * @param {boolean} [options.includeColumns=true] - Include column metadata
18
28
  * @param {boolean} [options.includeRelations=true] - Include relation metadata
19
29
  * @param {boolean} [options.includeScopes=true] - Include scope configuration
20
30
  * @param {Function} [options.authorize] - Custom authorization function (req) => boolean
31
+ * @param {boolean} [options.admin=true] - Enable Admin Panel module when admin-panel plugin is present
32
+ * @param {string} [options.title] - Admin page title (default: menuLabel or 'Schema Explorer')
33
+ * @param {string} [options.adminTitle] - Admin page title alias
34
+ * @param {string} [options.adminPath='/schema'] - Path within admin panel
35
+ * @param {string} [options.menuLabel='Schema Explorer'] - Admin sidebar menu label
36
+ * @param {string} [options.menuIcon='database'] - Admin sidebar icon
37
+ * @param {number} [options.menuOrder=85] - Admin sidebar menu order
38
+ * @param {string} [options.apiPrefix='/schema'] - Admin API prefix under /_admin/api
21
39
  * @returns {Object} Plugin definition
22
40
  */
23
41
  function schemaExplorerPlugin(options = {}) {
@@ -29,13 +47,24 @@ function schemaExplorerPlugin(options = {}) {
29
47
  includeRelations = true,
30
48
  includeScopes = true,
31
49
  authorize,
50
+ admin = true,
51
+ adminPath = '/schema',
52
+ title,
53
+ adminTitle,
54
+ menuLabel = 'Schema Explorer',
55
+ menuIcon = 'database',
56
+ menuOrder = 85,
57
+ apiPrefix = '/schema',
32
58
  } = options;
33
59
 
60
+ const pageTitle = title || adminTitle || menuLabel;
61
+
34
62
  return {
35
63
  name: 'schema-explorer',
36
64
  version: '1.0.0',
65
+ description: 'Database schema, ER diagram & relation explorer for API and Admin Panel',
37
66
 
38
- // Expose API for other plugins
67
+ // Expose API for other plugins & programmatic consumption
39
68
  api: {
40
69
  /**
41
70
  * Get all models metadata
@@ -45,6 +74,16 @@ function schemaExplorerPlugin(options = {}) {
45
74
  return serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
46
75
  },
47
76
 
77
+ /**
78
+ * Get all models enriched with incoming/outgoing relationships graph
79
+ * @returns {Object[]}
80
+ */
81
+ getEnrichedModels() {
82
+ const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
83
+ const rels = extractAllRelationships(raw);
84
+ return enrichModelsWithGraph(raw, rels);
85
+ },
86
+
48
87
  /**
49
88
  * Get single model metadata
50
89
  * @param {string} name - Model name
@@ -64,81 +103,217 @@ function schemaExplorerPlugin(options = {}) {
64
103
  const models = getAllModels();
65
104
  return [...models.keys()].filter(name => !exclude.includes(name));
66
105
  },
67
- },
68
106
 
69
- onRoutesReady(ctx) {
70
- const isDev = process.env.NODE_ENV !== 'production';
71
- const isEnabled = enabled !== undefined ? enabled : isDev;
107
+ /**
108
+ * Get unified relationships graph
109
+ * @returns {Object[]}
110
+ */
111
+ getRelationships() {
112
+ const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
113
+ return extractAllRelationships(raw);
114
+ },
72
115
 
73
- if (!isEnabled) {
74
- return;
75
- }
116
+ /**
117
+ * Generate Mermaid ER diagram syntax
118
+ * @returns {string}
119
+ */
120
+ getMermaid() {
121
+ const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
122
+ const rels = extractAllRelationships(raw);
123
+ return generateMermaidErDiagram(raw, rels);
124
+ },
76
125
 
77
- // List all models
78
- ctx.addRoute('get', path, (req, res) => {
79
- // Check authorization
80
- if (authorize && !authorize(req)) {
81
- return res.status(403).json({ error: 'Forbidden' });
82
- }
126
+ extractAllRelationships,
127
+ enrichModelsWithGraph,
128
+ generateMermaidErDiagram,
129
+ },
83
130
 
84
- const models = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
131
+ onRoutesReady(ctx) {
132
+ const isDev = process.env.NODE_ENV !== 'production';
133
+ const isRawEnabled = enabled !== undefined ? enabled : isDev;
134
+
135
+ // 1. Raw API Endpoints (if enabled or dev mode)
136
+ if (isRawEnabled) {
137
+ // List all models with schema metadata
138
+ ctx.addRoute('get', path, (req, res) => {
139
+ if (authorize && !authorize(req)) {
140
+ return res.status(403).json({ error: 'Forbidden' });
141
+ }
142
+
143
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
144
+ const relationships = extractAllRelationships(rawModels);
145
+ const models = enrichModelsWithGraph(rawModels, relationships);
146
+
147
+ res.json({
148
+ meta: {
149
+ version: '1.0.0',
150
+ generatedAt: new Date().toISOString(),
151
+ modelCount: models.length,
152
+ relationCount: relationships.length,
153
+ },
154
+ models,
155
+ relationships,
156
+ });
157
+ });
85
158
 
86
- res.json({
87
- meta: {
88
- version: '1.0.0',
89
- generatedAt: new Date().toISOString(),
90
- modelCount: models.length,
91
- },
92
- models,
159
+ // OpenAPI/JSON Schema export (must be registered before :modelName route)
160
+ ctx.addRoute('get', `${path}/openapi`, (req, res) => {
161
+ if (authorize && !authorize(req)) {
162
+ return res.status(403).json({ error: 'Forbidden' });
163
+ }
164
+
165
+ const openApiSchemas = generateOrmOpenApiSchemas({ exclude });
166
+
167
+ res.json({
168
+ openapi: '3.0.0',
169
+ info: {
170
+ title: 'Database Schema',
171
+ version: '1.0.0',
172
+ },
173
+ components: {
174
+ schemas: openApiSchemas,
175
+ },
176
+ });
93
177
  });
94
- });
95
178
 
96
- // OpenAPI/JSON Schema export (must be registered before :modelName route)
97
- ctx.addRoute('get', `${path}/openapi`, (req, res) => {
98
- // Check authorization
99
- if (authorize && !authorize(req)) {
100
- return res.status(403).json({ error: 'Forbidden' });
101
- }
179
+ // Mermaid ER Diagram export
180
+ ctx.addRoute('get', `${path}/mermaid`, (req, res) => {
181
+ if (authorize && !authorize(req)) {
182
+ return res.status(403).json({ error: 'Forbidden' });
183
+ }
184
+
185
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
186
+ const relationships = extractAllRelationships(rawModels);
187
+ const mermaid = generateMermaidErDiagram(rawModels, relationships);
188
+
189
+ if (req.accepts('text/plain') && !req.accepts('json')) {
190
+ res.type('text/plain').send(mermaid);
191
+ } else {
192
+ res.json({ mermaid });
193
+ }
194
+ });
102
195
 
103
- const openApiSchemas = generateOrmOpenApiSchemas({ exclude });
104
-
105
- res.json({
106
- openapi: '3.0.0',
107
- info: {
108
- title: 'Database Schema',
109
- version: '1.0.0',
110
- },
111
- components: {
112
- schemas: openApiSchemas,
113
- },
196
+ // Get single model by name
197
+ ctx.addRoute('get', `${path}/:modelName`, (req, res) => {
198
+ if (authorize && !authorize(req)) {
199
+ return res.status(403).json({ error: 'Forbidden' });
200
+ }
201
+
202
+ const { modelName } = req.params;
203
+ const model = getModel(modelName);
204
+
205
+ if (!model || exclude.includes(modelName)) {
206
+ return res.status(404).json({ error: `Model "${modelName}" not found` });
207
+ }
208
+
209
+ res.json({
210
+ meta: {
211
+ version: '1.0.0',
212
+ generatedAt: new Date().toISOString(),
213
+ },
214
+ model: serializeModel(model, { includeColumns, includeRelations, includeScopes }),
215
+ });
114
216
  });
115
- });
116
217
 
117
- // Get single model by name (must be after /openapi to avoid matching "openapi" as modelName)
118
- ctx.addRoute('get', `${path}/:modelName`, (req, res) => {
119
- // Check authorization
120
- if (authorize && !authorize(req)) {
121
- return res.status(403).json({ error: 'Forbidden' });
218
+ if (isDev) {
219
+ console.log(` 📋 Schema Explorer API: ${path}`);
122
220
  }
221
+ }
123
222
 
124
- const { modelName } = req.params;
125
- const model = getModel(modelName);
126
-
127
- if (!model || exclude.includes(modelName)) {
128
- return res.status(404).json({ error: `Model "${modelName}" not found` });
223
+ // 2. Admin Panel Integration (when adminPanelPlugin is present and admin !== false)
224
+ if (admin !== false && typeof ctx.usePlugin === 'function') {
225
+ const adminApi = ctx.usePlugin('admin-panel');
226
+ if (adminApi) {
227
+ const normAdminPath = adminPath.startsWith('/') ? adminPath : '/' + adminPath;
228
+
229
+ adminApi.registerModule({
230
+ id: 'schema-explorer',
231
+ title: pageTitle,
232
+ pages: [{
233
+ id: 'schema-explorer',
234
+ title: pageTitle,
235
+ path: normAdminPath,
236
+ icon: menuIcon,
237
+ component: generateSchemaExplorerAdminComponent({ apiPrefix }),
238
+ }],
239
+ menu: [{
240
+ id: 'schema-explorer',
241
+ label: menuLabel,
242
+ path: normAdminPath,
243
+ icon: menuIcon,
244
+ order: menuOrder,
245
+ }],
246
+ api: {
247
+ prefix: apiPrefix,
248
+ routes: [
249
+ {
250
+ method: 'get',
251
+ path: '',
252
+ handler: (req, res) => {
253
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
254
+ const relationships = extractAllRelationships(rawModels);
255
+ const models = enrichModelsWithGraph(rawModels, relationships);
256
+ const mermaid = generateMermaidErDiagram(rawModels, relationships);
257
+
258
+ res.json({
259
+ meta: {
260
+ version: '1.0.0',
261
+ generatedAt: new Date().toISOString(),
262
+ modelCount: models.length,
263
+ relationCount: relationships.length,
264
+ },
265
+ models,
266
+ relationships,
267
+ mermaid,
268
+ });
269
+ },
270
+ },
271
+ {
272
+ method: 'get',
273
+ path: '/relations',
274
+ handler: (req, res) => {
275
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
276
+ const relationships = extractAllRelationships(rawModels);
277
+ res.json({ relationships });
278
+ },
279
+ },
280
+ {
281
+ method: 'get',
282
+ path: '/mermaid',
283
+ handler: (req, res) => {
284
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
285
+ const relationships = extractAllRelationships(rawModels);
286
+ const mermaid = generateMermaidErDiagram(rawModels, relationships);
287
+ res.json({ mermaid });
288
+ },
289
+ },
290
+ {
291
+ method: 'get',
292
+ path: '/models/:modelName',
293
+ handler: (req, res) => {
294
+ const { modelName } = req.params;
295
+ const model = getModel(modelName);
296
+
297
+ if (!model || exclude.includes(modelName)) {
298
+ return res.status(404).json({ error: `Model "${modelName}" not found` });
299
+ }
300
+
301
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
302
+ const relationships = extractAllRelationships(rawModels);
303
+ const models = enrichModelsWithGraph(rawModels, relationships);
304
+ const found = models.find(m => m.name === modelName);
305
+
306
+ res.json({ model: found });
307
+ },
308
+ },
309
+ ],
310
+ },
311
+ });
312
+
313
+ if (isDev) {
314
+ console.log(` 🗄️ Schema Explorer Admin: ${adminApi.getAdminPath ? adminApi.getAdminPath() : '/_admin'}${normAdminPath}`);
315
+ }
129
316
  }
130
-
131
- res.json({
132
- meta: {
133
- version: '1.0.0',
134
- generatedAt: new Date().toISOString(),
135
- },
136
- model: serializeModel(model, { includeColumns, includeRelations, includeScopes }),
137
- });
138
- });
139
-
140
- if (isDev) {
141
- console.log(` 📋 Schema Explorer: ${path}`);
142
317
  }
143
318
  },
144
319
  };
@@ -264,4 +439,8 @@ function serializeRelations(relations) {
264
439
  }
265
440
 
266
441
  module.exports = schemaExplorerPlugin;
267
-
442
+ module.exports.schemaExplorerPlugin = schemaExplorerPlugin;
443
+ module.exports.extractAllRelationships = extractAllRelationships;
444
+ module.exports.enrichModelsWithGraph = enrichModelsWithGraph;
445
+ module.exports.generateMermaidErDiagram = generateMermaidErDiagram;
446
+ module.exports.generateSchemaExplorerAdminComponent = generateSchemaExplorerAdminComponent;
@@ -305,6 +305,29 @@ var AnalyticsPage = {
305
305
  onclick: function() { AnalyticsPage.setDays(vnode, d); },
306
306
  }, 'Last ' + d + ' days');
307
307
  })),
308
+ // Purge old data button
309
+ m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.border.border-gray-200.dark:border-slate-700.text-gray-600.dark:text-slate-300.hover:bg-gray-50.dark:hover:bg-slate-700.transition-colors.flex.items-center.gap-1.5', {
310
+ title: 'Purge analytics data older than 90 days and error logs older than 30 days',
311
+ disabled: s.purging,
312
+ onclick: function() {
313
+ if (!confirm('Are you sure you want to purge analytics data older than 90 days and error logs older than 30 days?')) return;
314
+ s.purging = true;
315
+ m.redraw();
316
+ api.post('/analytics/purge', { days: 90, errorDays: 30 })
317
+ .then(function(res) {
318
+ s.purging = false;
319
+ var views = (res && res.viewsDeleted != null) ? res.viewsDeleted : 0;
320
+ var errs = (res && res.errorsDeleted != null) ? res.errorsDeleted : 0;
321
+ alert('Purged ' + views + ' page views and ' + errs + ' client errors.');
322
+ AnalyticsPage.loadData(vnode);
323
+ })
324
+ .catch(function(err) {
325
+ s.purging = false;
326
+ alert('Failed to purge analytics: ' + (err && err.message ? err.message : String(err)));
327
+ m.redraw();
328
+ });
329
+ },
330
+ }, s.purging ? 'Purging...' : 'Purge Old Data'),
308
331
  ]),
309
332
  ]),
310
333
 
@@ -329,6 +329,47 @@ function createAnalyticsApiHandlers(options) {
329
329
  }
330
330
  }
331
331
 
332
+ /**
333
+ * POST /purge - Purge old analytics data
334
+ */
335
+ async function purge(req, res) {
336
+ try {
337
+ const rawDays = req.body?.days !== undefined ? req.body.days : req.query?.days;
338
+ let days = 90;
339
+ if (rawDays !== undefined && rawDays !== null && rawDays !== '') {
340
+ days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
341
+ if (Number.isNaN(days) || days <= 0) {
342
+ return res.status(400).json({ error: 'days must be a positive number' });
343
+ }
344
+ }
345
+
346
+ const rawErrorDays = req.body?.errorDays !== undefined ? req.body.errorDays : req.query?.errorDays;
347
+ let errorDays = 30;
348
+ if (rawErrorDays !== undefined && rawErrorDays !== null && rawErrorDays !== '') {
349
+ errorDays = typeof rawErrorDays === 'number' ? rawErrorDays : Number(rawErrorDays);
350
+ if (Number.isNaN(errorDays) || errorDays <= 0) {
351
+ return res.status(400).json({ error: 'errorDays must be a positive number' });
352
+ }
353
+ }
354
+
355
+ const { purgeAnalyticsData } = require('./purge');
356
+ const result = await purgeAnalyticsData(knex, {
357
+ tableName,
358
+ errorsTableName,
359
+ days,
360
+ errorDays,
361
+ });
362
+
363
+ res.json({
364
+ ok: true,
365
+ success: true,
366
+ ...result,
367
+ });
368
+ } catch (e) {
369
+ res.status(500).json({ error: e.message });
370
+ }
371
+ }
372
+
332
373
  return {
333
374
  getStats,
334
375
  getViewsOverTime,
@@ -338,6 +379,7 @@ function createAnalyticsApiHandlers(options) {
338
379
  getCountries,
339
380
  getClientErrors,
340
381
  getRecent,
382
+ purge,
341
383
  };
342
384
  }
343
385