webspresso 0.0.97 → 0.0.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -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,
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return res.status(403).json({ error: 'Forbidden' });
|
|
82
|
-
}
|
|
126
|
+
extractAllRelationships,
|
|
127
|
+
enrichModelsWithGraph,
|
|
128
|
+
generateMermaidErDiagram,
|
|
129
|
+
},
|
|
83
130
|
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
|