webspresso 0.0.96 → 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 (121) 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/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -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;
@@ -203,12 +203,12 @@ var AnalyticsPage = {
203
203
  vnode.state.chartLoaded = false;
204
204
  vnode.state._stopPoll = null;
205
205
  vnode.state.chartDataVersion = 0;
206
- this.loadData(vnode);
207
- this.loadChartJs(vnode);
206
+ AnalyticsPage.loadData(vnode);
207
+ AnalyticsPage.loadChartJs(vnode);
208
208
  if (typeof runAdminAutoRefresh === 'function') {
209
209
  vnode.state._stopPoll = runAdminAutoRefresh(function() {
210
- this.loadData(vnode);
211
- }.bind(this));
210
+ AnalyticsPage.loadData(vnode);
211
+ });
212
212
  }
213
213
  },
214
214
 
@@ -270,12 +270,11 @@ var AnalyticsPage = {
270
270
  setDays: function(vnode, d) {
271
271
  vnode.state.days = d;
272
272
  if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
273
- this.loadData(vnode);
273
+ AnalyticsPage.loadData(vnode);
274
274
  },
275
275
 
276
276
  view: function(vnode) {
277
277
  var s = vnode.state;
278
- var self = this;
279
278
 
280
279
  return m(Layout, [
281
280
  m(Breadcrumb, { items: [{ label: 'Analytics', href: '/analytics' }] }),
@@ -294,7 +293,7 @@ var AnalyticsPage = {
294
293
  ? m(RefreshIconButton, {
295
294
  title: 'Refresh analytics',
296
295
  spinning: s.loading,
297
- onclick: function() { self.loadData(vnode); },
296
+ onclick: function() { AnalyticsPage.loadData(vnode); },
298
297
  })
299
298
  : null,
300
299
  // Day filter
@@ -303,9 +302,32 @@ var AnalyticsPage = {
303
302
  class: s.days === d
304
303
  ? 'bg-white text-gray-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
305
304
  : 'text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200',
306
- onclick: function() { self.setDays(vnode, d); },
305
+ onclick: function() { AnalyticsPage.setDays(vnode, d); },
307
306
  }, 'Last ' + d + ' days');
308
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'),
309
331
  ]),
310
332
  ]),
311
333