webspresso 0.0.90 → 0.0.91
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/README.md +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +2 -0
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +90 -17
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
|
@@ -425,8 +425,8 @@ const RecordList = {
|
|
|
425
425
|
// Sticky header
|
|
426
426
|
m('thead.bg-gray-50.dark:bg-slate-900', { style: 'position: sticky; top: 0; z-index: 20;' }, [
|
|
427
427
|
m('tr', [
|
|
428
|
-
// Checkbox column header (sticky left
|
|
429
|
-
m('th.px-4.py-3.text-left.bg-gray-50
|
|
428
|
+
// Checkbox column header (sticky left on md+, box-shadow on right)
|
|
429
|
+
m('th.px-4.py-3.text-left.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.admin-table-sticky-check', { style: 'width: 40px;' }, [
|
|
430
430
|
m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
431
431
|
checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
|
|
432
432
|
indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
|
|
@@ -440,23 +440,21 @@ const RecordList = {
|
|
|
440
440
|
},
|
|
441
441
|
}),
|
|
442
442
|
]),
|
|
443
|
-
// Dynamic column headers (first column sticky left with box-shadow)
|
|
443
|
+
// Dynamic column headers (first column sticky left on md+ with box-shadow)
|
|
444
444
|
...displayColumns.map((col, i) => {
|
|
445
445
|
const isSortable = col.sortable !== false;
|
|
446
446
|
const isSorted = isSortable && state.sortColumn === col.name;
|
|
447
447
|
const dir = isSorted ? state.sortDirection : null;
|
|
448
|
-
const
|
|
448
|
+
const stickyClass = i === 0 ? '.admin-table-sticky-first' : '';
|
|
449
449
|
|
|
450
450
|
if (!isSortable) {
|
|
451
|
-
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700',
|
|
452
|
-
stickyStyle ? { style: stickyStyle } : {},
|
|
451
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700' + stickyClass,
|
|
453
452
|
formatColumnLabel(col.name)
|
|
454
453
|
);
|
|
455
454
|
}
|
|
456
455
|
|
|
457
|
-
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group',
|
|
456
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group' + stickyClass,
|
|
458
457
|
{
|
|
459
|
-
style: stickyStyle || undefined,
|
|
460
458
|
onclick: () => {
|
|
461
459
|
let nextSort = col.name;
|
|
462
460
|
let nextDir = 'asc';
|
|
@@ -489,21 +487,19 @@ const RecordList = {
|
|
|
489
487
|
])
|
|
490
488
|
);
|
|
491
489
|
}),
|
|
492
|
-
// Sticky actions header (sticky right
|
|
493
|
-
m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500
|
|
494
|
-
style: '
|
|
490
|
+
// Sticky actions header (sticky right on md+, box-shadow on left)
|
|
491
|
+
m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.admin-table-sticky-actions', {
|
|
492
|
+
style: 'min-width: 120px;',
|
|
495
493
|
}, 'Actions'),
|
|
496
494
|
]),
|
|
497
495
|
]),
|
|
498
496
|
m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', state.records.map(record =>
|
|
499
|
-
m('tr.hover:bg-gray-50
|
|
497
|
+
m('tr.hover:bg-gray-50.dark:hover:bg-slate-800/50.transition-colors', {
|
|
500
498
|
class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50 dark:bg-indigo-950/50' : '',
|
|
501
499
|
}, [
|
|
502
|
-
// Checkbox cell (sticky left
|
|
503
|
-
m('td.px-4.py-3.bg-white.dark:bg-slate-800',
|
|
504
|
-
|
|
505
|
-
}, [
|
|
506
|
-
m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
500
|
+
// Checkbox cell (sticky left on md+, box-shadow on right)
|
|
501
|
+
m('td.px-4.py-3.bg-white.dark:bg-slate-800.admin-table-sticky-check', [
|
|
502
|
+
m('input[type=checkbox].rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
507
503
|
checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
|
|
508
504
|
onchange: (e) => {
|
|
509
505
|
if (!state.selectedRecords) state.selectedRecords = new Set();
|
|
@@ -516,17 +512,14 @@ const RecordList = {
|
|
|
516
512
|
},
|
|
517
513
|
}),
|
|
518
514
|
]),
|
|
519
|
-
// Dynamic cell values (first column sticky left with box-shadow)
|
|
515
|
+
// Dynamic cell values (first column sticky left on md+ with box-shadow)
|
|
520
516
|
...displayColumns.map((col, i) =>
|
|
521
|
-
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700
|
|
522
|
-
i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
|
|
517
|
+
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700.dark:text-slate-300.bg-white.dark:bg-slate-800' + (i === 0 ? '.admin-table-sticky-first' : ''),
|
|
523
518
|
formatCellValue(record[col.name], col, record)
|
|
524
519
|
)
|
|
525
520
|
),
|
|
526
|
-
// Sticky actions cell (sticky right
|
|
527
|
-
m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.text-gray-700
|
|
528
|
-
style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
|
|
529
|
-
}, [
|
|
521
|
+
// Sticky actions cell (sticky right on md+, box-shadow on left)
|
|
522
|
+
m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.text-gray-700.dark:text-slate-300.bg-white.dark:bg-slate-800.admin-table-sticky-actions', [
|
|
530
523
|
state.trashedView && modelMeta?.softDelete
|
|
531
524
|
? m('button.inline-flex.items-center.px-2.py-1.text-sm.text-green-600.dark:text-green-400.hover:text-green-800.dark:hover:text-green-300.hover:bg-green-50.dark:hover:bg-green-950/40.rounded.transition-colors', {
|
|
532
525
|
onclick: async () => {
|
|
@@ -1,9 +1,141 @@
|
|
|
1
|
-
// Record Form Component - renders fields based on model schema
|
|
1
|
+
// Record Form Component - renders fields based on model schema with dynamic validation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate a single column value against schema and metadata rules
|
|
5
|
+
*/
|
|
6
|
+
function validateField(col, value, formData) {
|
|
7
|
+
const validations = col.validations || {};
|
|
8
|
+
const isRequired = !col.nullable;
|
|
9
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
10
|
+
|
|
11
|
+
// 1. Required / Nullable check
|
|
12
|
+
if (isRequired) {
|
|
13
|
+
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
|
14
|
+
return label + ' is required';
|
|
15
|
+
}
|
|
16
|
+
if (col.customField && col.customField.type === 'rich-text' && isRichTextEmpty(value)) {
|
|
17
|
+
return label + ' is required';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// If value is empty and column is nullable, it's valid
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Type validation
|
|
27
|
+
if (col.type === 'integer' || col.type === 'bigint') {
|
|
28
|
+
const num = Number(value);
|
|
29
|
+
if (isNaN(num) || !Number.isInteger(num)) {
|
|
30
|
+
return label + ' must be an integer';
|
|
31
|
+
}
|
|
32
|
+
} else if (col.type === 'float' || col.type === 'decimal') {
|
|
33
|
+
const num = Number(value);
|
|
34
|
+
if (isNaN(num)) {
|
|
35
|
+
return label + ' must be a valid number';
|
|
36
|
+
}
|
|
37
|
+
} else if (col.type === 'json') {
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
try {
|
|
40
|
+
JSON.parse(value);
|
|
41
|
+
} catch {
|
|
42
|
+
return label + ' must be valid JSON';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. String Length & Format Validations
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
const minLen = validations.minLength !== undefined ? validations.minLength : validations.min;
|
|
50
|
+
if (minLen !== undefined && value.length < minLen) {
|
|
51
|
+
return label + ' must be at least ' + minLen + ' characters';
|
|
52
|
+
}
|
|
53
|
+
const maxLen = validations.maxLength !== undefined ? validations.maxLength : (validations.max || col.maxLength);
|
|
54
|
+
if (maxLen !== undefined && value.length > maxLen) {
|
|
55
|
+
return label + ' must be at most ' + maxLen + ' characters';
|
|
56
|
+
}
|
|
57
|
+
if (validations.email) {
|
|
58
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
59
|
+
if (!emailRegex.test(value.trim())) {
|
|
60
|
+
return label + ' must be a valid email address';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (validations.url) {
|
|
64
|
+
try {
|
|
65
|
+
new URL(value.trim());
|
|
66
|
+
} catch {
|
|
67
|
+
return label + ' must be a valid URL';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (validations.pattern) {
|
|
71
|
+
try {
|
|
72
|
+
const regex = new RegExp(validations.pattern);
|
|
73
|
+
if (!regex.test(value)) {
|
|
74
|
+
return label + ' format is invalid';
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Numeric Bounds
|
|
81
|
+
if (typeof value === 'number') {
|
|
82
|
+
if (validations.min !== undefined && value < validations.min) {
|
|
83
|
+
return label + ' must be at least ' + validations.min;
|
|
84
|
+
}
|
|
85
|
+
if (validations.max !== undefined && value > validations.max) {
|
|
86
|
+
return label + ' must be at most ' + validations.max;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5. Enum validation
|
|
91
|
+
if (col.enumValues && Array.isArray(col.enumValues) && col.enumValues.length > 0) {
|
|
92
|
+
if (!col.enumValues.includes(value)) {
|
|
93
|
+
return label + ' must be one of: ' + col.enumValues.join(', ');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 6. Custom Field Validator
|
|
98
|
+
if (col.customField && typeof col.customField.validate === 'function') {
|
|
99
|
+
const customErr = col.customField.validate(value, formData);
|
|
100
|
+
if (customErr) return customErr;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate all columns in a form
|
|
108
|
+
*/
|
|
109
|
+
function validateForm(columns, formData) {
|
|
110
|
+
const errors = {};
|
|
111
|
+
if (!columns || !Array.isArray(columns)) return errors;
|
|
112
|
+
|
|
113
|
+
for (const col of columns) {
|
|
114
|
+
const autoType = isAutoColumn(col);
|
|
115
|
+
if (autoType === 'primary' || autoType === 'auto') continue;
|
|
116
|
+
if (col.ui && col.ui.hidden) continue;
|
|
117
|
+
|
|
118
|
+
let val = formData[col.name];
|
|
119
|
+
if (col.customField && col.customField.type === 'rich-text') {
|
|
120
|
+
const hiddenInput = document.getElementById(col.name + '-value');
|
|
121
|
+
if (hiddenInput) val = hiddenInput.value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const err = validateField(col, val, formData);
|
|
125
|
+
if (err) {
|
|
126
|
+
errors[col.name] = err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return errors;
|
|
131
|
+
}
|
|
132
|
+
|
|
2
133
|
const RecordForm = {
|
|
3
134
|
oninit: () => {
|
|
4
135
|
const modelName = m.route.param('model');
|
|
5
136
|
const id = m.route.param('id');
|
|
6
137
|
state.error = null;
|
|
138
|
+
state.fieldErrors = {};
|
|
7
139
|
state.loading = true;
|
|
8
140
|
state.formData = {};
|
|
9
141
|
state.currentModelMeta = null;
|
|
@@ -66,24 +198,32 @@ const RecordForm = {
|
|
|
66
198
|
style: 'min-height: calc(100vh - 280px);',
|
|
67
199
|
onsubmit: async (e) => {
|
|
68
200
|
e.preventDefault();
|
|
69
|
-
state.loading = true;
|
|
70
201
|
state.error = null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
202
|
+
state.fieldErrors = {};
|
|
203
|
+
|
|
204
|
+
// 1. Run client-side validation
|
|
205
|
+
if (modelMeta && modelMeta.columns) {
|
|
206
|
+
const validationErrors = validateForm(modelMeta.columns, state.formData);
|
|
207
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
208
|
+
state.fieldErrors = validationErrors;
|
|
209
|
+
state.error = 'Please fix the errors indicated below.';
|
|
210
|
+
m.redraw();
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
const firstInvalidKey = Object.keys(validationErrors)[0];
|
|
213
|
+
const targetEl = document.getElementById(firstInvalidKey) ||
|
|
214
|
+
document.getElementById('file-input-' + firstInvalidKey) ||
|
|
215
|
+
document.getElementById('quill-editor-' + firstInvalidKey);
|
|
216
|
+
if (targetEl && targetEl.scrollIntoView) {
|
|
217
|
+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
218
|
+
if (typeof targetEl.focus === 'function') targetEl.focus();
|
|
83
219
|
}
|
|
84
|
-
}
|
|
220
|
+
}, 50);
|
|
221
|
+
return;
|
|
85
222
|
}
|
|
86
|
-
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
state.loading = true;
|
|
226
|
+
try {
|
|
87
227
|
// Build payload, excluding auto-generated fields
|
|
88
228
|
const payload = {};
|
|
89
229
|
if (modelMeta && modelMeta.columns) {
|
|
@@ -123,9 +263,13 @@ const RecordForm = {
|
|
|
123
263
|
}
|
|
124
264
|
m.route.set('/models/' + modelName);
|
|
125
265
|
} catch (err) {
|
|
126
|
-
state.error = err.message;
|
|
266
|
+
state.error = err.message || 'Failed to save record';
|
|
267
|
+
if (err.fields && typeof err.fields === 'object') {
|
|
268
|
+
state.fieldErrors = err.fields;
|
|
269
|
+
}
|
|
127
270
|
} finally {
|
|
128
271
|
state.loading = false;
|
|
272
|
+
m.redraw();
|
|
129
273
|
}
|
|
130
274
|
}
|
|
131
275
|
}, [
|
|
@@ -146,11 +290,16 @@ const RecordForm = {
|
|
|
146
290
|
const isReadonly = !!autoType || (col.ui && col.ui.readonly);
|
|
147
291
|
const renderer = getFieldRenderer(col, modelMeta);
|
|
148
292
|
const value = state.formData[col.name];
|
|
293
|
+
const fieldError = state.fieldErrors ? state.fieldErrors[col.name] : null;
|
|
294
|
+
|
|
149
295
|
const onChange = (newValue) => {
|
|
150
296
|
state.formData[col.name] = newValue;
|
|
297
|
+
if (state.fieldErrors && state.fieldErrors[col.name]) {
|
|
298
|
+
delete state.fieldErrors[col.name];
|
|
299
|
+
}
|
|
151
300
|
};
|
|
152
301
|
|
|
153
|
-
return renderer(col, value, onChange, isReadonly);
|
|
302
|
+
return renderer(col, value, onChange, isReadonly, fieldError);
|
|
154
303
|
}) : m('p.text-gray-600 dark:text-slate-400.mb-4', 'Loading form fields...'),
|
|
155
304
|
]),
|
|
156
305
|
|
|
@@ -160,7 +309,7 @@ const RecordForm = {
|
|
|
160
309
|
type: 'submit',
|
|
161
310
|
disabled: state.loading,
|
|
162
311
|
}, state.loading ? 'Saving...' : 'Save'),
|
|
163
|
-
m('button.bg-gray-200
|
|
312
|
+
m('button.bg-gray-200.dark:bg-slate-700.text-gray-800.dark:text-slate-200.px-6.py-2.rounded.hover:bg-gray-300.dark:hover:bg-slate-600[type=button]', {
|
|
164
313
|
onclick: () => m.route.set('/models/' + modelName),
|
|
165
314
|
}, 'Cancel'),
|
|
166
315
|
]),
|
|
@@ -8,7 +8,7 @@ function formatClientComponent(pageId, rawCode) {
|
|
|
8
8
|
let code = (rawCode || '').trim();
|
|
9
9
|
if (!code) return '';
|
|
10
10
|
if (code.includes('window.__customPages')) {
|
|
11
|
-
return code
|
|
11
|
+
return `try {\n${code}\n} catch (err) {\n console.error('[Admin Panel] Failed to load custom page component "${pageId}":', err);\n}`;
|
|
12
12
|
}
|
|
13
13
|
if (code.startsWith('module.exports =')) {
|
|
14
14
|
code = code.replace(/^module\.exports\s*=\s*/, '');
|
|
@@ -16,7 +16,7 @@ function formatClientComponent(pageId, rawCode) {
|
|
|
16
16
|
if (code.endsWith(';')) {
|
|
17
17
|
code = code.slice(0, -1);
|
|
18
18
|
}
|
|
19
|
-
return `window.__customPages = window.__customPages || {};\
|
|
19
|
+
return `try {\n window.__customPages = window.__customPages || {};\n window.__customPages[${JSON.stringify(pageId)}] = ${code};\n} catch (err) {\n console.error('[Admin Panel] Failed to load custom page component "${pageId}":', err);\n}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -4,8 +4,113 @@
|
|
|
4
4
|
* @module plugins/admin-panel/core/api-extensions
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const https = require('https');
|
|
7
9
|
const { sanitizeForOutput } = require('../../../core/orm/utils');
|
|
8
10
|
|
|
11
|
+
function getWebspressoVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = require('../../../package.json');
|
|
14
|
+
return pkg.version || '0.0.1';
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.1';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let npmVersionCache = {
|
|
21
|
+
latestVersion: null,
|
|
22
|
+
checkedAt: 0,
|
|
23
|
+
inFlight: null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fetchLatestNpmVersion(force = false) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
if (!force && npmVersionCache.latestVersion && (now - npmVersionCache.checkedAt < 30 * 60 * 1000)) {
|
|
29
|
+
return Promise.resolve(npmVersionCache.latestVersion);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (npmVersionCache.inFlight) {
|
|
33
|
+
return npmVersionCache.inFlight;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
npmVersionCache.inFlight = new Promise((resolve) => {
|
|
37
|
+
const req = https.get('https://registry.npmjs.org/webspresso/latest', {
|
|
38
|
+
headers: {
|
|
39
|
+
'User-Agent': 'Webspresso-Admin-Version-Check',
|
|
40
|
+
'Accept': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
timeout: 3000,
|
|
43
|
+
}, (res) => {
|
|
44
|
+
if (res.statusCode !== 200) {
|
|
45
|
+
res.resume();
|
|
46
|
+
return resolve(npmVersionCache.latestVersion);
|
|
47
|
+
}
|
|
48
|
+
let rawData = '';
|
|
49
|
+
res.setEncoding('utf8');
|
|
50
|
+
res.on('data', (chunk) => { rawData += chunk; });
|
|
51
|
+
res.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(rawData);
|
|
54
|
+
if (parsed && typeof parsed.version === 'string') {
|
|
55
|
+
npmVersionCache.latestVersion = parsed.version;
|
|
56
|
+
npmVersionCache.checkedAt = Date.now();
|
|
57
|
+
return resolve(parsed.version);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
npmVersionCache.checkedAt = Date.now();
|
|
61
|
+
resolve(npmVersionCache.latestVersion);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
req.on('timeout', () => {
|
|
66
|
+
req.destroy();
|
|
67
|
+
npmVersionCache.checkedAt = Date.now();
|
|
68
|
+
resolve(npmVersionCache.latestVersion);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
req.on('error', () => {
|
|
72
|
+
npmVersionCache.checkedAt = Date.now();
|
|
73
|
+
resolve(npmVersionCache.latestVersion);
|
|
74
|
+
});
|
|
75
|
+
}).finally(() => {
|
|
76
|
+
npmVersionCache.inFlight = null;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return npmVersionCache.inFlight;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareSemver(v1, v2) {
|
|
83
|
+
if (!v1 || !v2) return 0;
|
|
84
|
+
const p1 = String(v1).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
85
|
+
const p2 = String(v2).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
86
|
+
for (let i = 0; i < Math.max(p1.length, p2.length); i++) {
|
|
87
|
+
const n1 = p1[i] || 0;
|
|
88
|
+
const n2 = p2[i] || 0;
|
|
89
|
+
if (n1 > n2) return 1;
|
|
90
|
+
if (n1 < n2) return -1;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatUptime(seconds) {
|
|
96
|
+
const sec = Math.floor(seconds || 0);
|
|
97
|
+
const d = Math.floor(sec / (3600 * 24));
|
|
98
|
+
const h = Math.floor((sec % (3600 * 24)) / 3600);
|
|
99
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
100
|
+
const s = Math.floor(sec % 60);
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (d > 0) parts.push(`${d}d`);
|
|
103
|
+
if (h > 0) parts.push(`${h}h`);
|
|
104
|
+
if (m > 0) parts.push(`${m}m`);
|
|
105
|
+
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
106
|
+
return parts.join(' ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatBytes(bytes) {
|
|
110
|
+
if (!bytes || bytes === 0) return '0 MB';
|
|
111
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
112
|
+
}
|
|
113
|
+
|
|
9
114
|
/**
|
|
10
115
|
* Build query with filters applied
|
|
11
116
|
* @param {Object} repo - Repository instance
|
|
@@ -730,6 +835,44 @@ function createExtensionApiHandlers(options) {
|
|
|
730
835
|
}
|
|
731
836
|
}
|
|
732
837
|
|
|
838
|
+
/**
|
|
839
|
+
* System info & Webspresso version check
|
|
840
|
+
*/
|
|
841
|
+
async function systemInfoHandler(req, res) {
|
|
842
|
+
try {
|
|
843
|
+
const force = req.query && req.query.force === 'true';
|
|
844
|
+
const currentVersion = getWebspressoVersion();
|
|
845
|
+
const latestVersion = await fetchLatestNpmVersion(force);
|
|
846
|
+
const hasUpdate = Boolean(latestVersion && compareSemver(latestVersion, currentVersion) > 0);
|
|
847
|
+
|
|
848
|
+
const mem = process.memoryUsage();
|
|
849
|
+
const dbClient = db?.knex?.client?.config?.client || 'knex';
|
|
850
|
+
|
|
851
|
+
res.json({
|
|
852
|
+
success: true,
|
|
853
|
+
webspressoVersion: currentVersion,
|
|
854
|
+
latestVersion: latestVersion || currentVersion,
|
|
855
|
+
hasUpdate,
|
|
856
|
+
nodeVersion: process.version,
|
|
857
|
+
platform: `${os.type()} ${os.release()} (${process.platform})`,
|
|
858
|
+
arch: process.arch,
|
|
859
|
+
environment: process.env.NODE_ENV || 'development',
|
|
860
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
861
|
+
uptimeFormatted: formatUptime(process.uptime()),
|
|
862
|
+
memory: {
|
|
863
|
+
heapUsed: formatBytes(mem.heapUsed),
|
|
864
|
+
heapTotal: formatBytes(mem.heapTotal),
|
|
865
|
+
rss: formatBytes(mem.rss),
|
|
866
|
+
},
|
|
867
|
+
database: {
|
|
868
|
+
client: String(dbClient),
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
} catch (error) {
|
|
872
|
+
res.status(500).json({ error: error.message });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
733
876
|
return {
|
|
734
877
|
configHandler,
|
|
735
878
|
widgetDataHandler,
|
|
@@ -742,6 +885,7 @@ function createExtensionApiHandlers(options) {
|
|
|
742
885
|
activityLogHandler,
|
|
743
886
|
settingsGetHandler,
|
|
744
887
|
settingsUpdateHandler,
|
|
888
|
+
systemInfoHandler,
|
|
745
889
|
};
|
|
746
890
|
}
|
|
747
891
|
|
|
@@ -750,4 +894,9 @@ module.exports = {
|
|
|
750
894
|
buildFilteredQuery,
|
|
751
895
|
getAllMatchingIds,
|
|
752
896
|
coerceBulkTemporalValue,
|
|
897
|
+
getWebspressoVersion,
|
|
898
|
+
fetchLatestNpmVersion,
|
|
899
|
+
compareSemver,
|
|
900
|
+
formatUptime,
|
|
901
|
+
formatBytes,
|
|
753
902
|
};
|
|
@@ -10,13 +10,14 @@ module.exports = {
|
|
|
10
10
|
|
|
11
11
|
// Load Quill if not already loaded
|
|
12
12
|
if (typeof window.Quill === 'undefined') {
|
|
13
|
+
const adminPath = (window.__ADMIN_PATH__) || '/_admin';
|
|
13
14
|
const link = document.createElement('link');
|
|
14
15
|
link.rel = 'stylesheet';
|
|
15
|
-
link.href = '
|
|
16
|
+
link.href = adminPath + '/vendor/quill.snow.min.css';
|
|
16
17
|
document.head.appendChild(link);
|
|
17
18
|
|
|
18
19
|
const script = document.createElement('script');
|
|
19
|
-
script.src = '
|
|
20
|
+
script.src = adminPath + '/vendor/quill.min.js';
|
|
20
21
|
script.onload = () => {
|
|
21
22
|
initEditor(vnode);
|
|
22
23
|
};
|