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.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -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/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/index.js +12 -7
- package/core/orm/model.js +49 -7
- 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/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -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 +10 -5
- 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 +243 -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/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/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/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -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/index.js +4 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -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 +3 -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/file-router.js +210 -62
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
|
@@ -135,6 +135,176 @@ function registerDashboardWidgets(options) {
|
|
|
135
135
|
return actions.slice(0, 5); // Max 5 quick actions
|
|
136
136
|
},
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
// Database Storage & Health widget
|
|
140
|
+
registry.registerWidget('database-storage', {
|
|
141
|
+
title: 'Database Storage & Health',
|
|
142
|
+
size: 'full',
|
|
143
|
+
order: 15,
|
|
144
|
+
dataLoader: async ({ db }) => {
|
|
145
|
+
return await collectDatabaseStorageStats(db);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Collect database storage and health statistics
|
|
152
|
+
* @param {Object} db - Database instance
|
|
153
|
+
* @returns {Promise<Object>}
|
|
154
|
+
*/
|
|
155
|
+
async function collectDatabaseStorageStats(db) {
|
|
156
|
+
if (!db || !db.knex) {
|
|
157
|
+
return {
|
|
158
|
+
engine: 'Unknown',
|
|
159
|
+
totalTables: 0,
|
|
160
|
+
totalRows: 0,
|
|
161
|
+
overallHealth: 'healthy',
|
|
162
|
+
tables: [],
|
|
163
|
+
sqliteInfo: null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const clientName = String(db.knex.client?.config?.client || 'unknown').toLowerCase();
|
|
168
|
+
const isSqlite = clientName.includes('sqlite');
|
|
169
|
+
const isPostgres = clientName.includes('pg') || clientName.includes('postgres');
|
|
170
|
+
const isMysql = clientName.includes('mysql');
|
|
171
|
+
|
|
172
|
+
const { getAllModels } = require('../../../core/orm/model');
|
|
173
|
+
const allModels = db.getAllModels ? db.getAllModels() : Array.from(getAllModels().values());
|
|
174
|
+
const modelList = Array.isArray(allModels) ? allModels : Array.from(allModels.values());
|
|
175
|
+
const modelByTable = new Map();
|
|
176
|
+
for (const m of modelList) {
|
|
177
|
+
if (m.table) modelByTable.set(m.table, m);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 1. Discover table names
|
|
181
|
+
const tableNamesSet = new Set();
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (isSqlite) {
|
|
185
|
+
const res = await db.knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';");
|
|
186
|
+
const rows = Array.isArray(res) ? res : (res?.rows || []);
|
|
187
|
+
for (const r of rows) {
|
|
188
|
+
if (r && r.name) tableNamesSet.add(r.name);
|
|
189
|
+
}
|
|
190
|
+
} else if (isPostgres) {
|
|
191
|
+
const res = await db.knex.raw("SELECT table_name as name FROM information_schema.tables WHERE table_schema='public';");
|
|
192
|
+
const rows = res?.rows || (Array.isArray(res) ? res : []);
|
|
193
|
+
for (const r of rows) {
|
|
194
|
+
if (r && r.name) tableNamesSet.add(r.name);
|
|
195
|
+
}
|
|
196
|
+
} else if (isMysql) {
|
|
197
|
+
const res = await db.knex.raw('SHOW TABLES;');
|
|
198
|
+
const rows = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : (Array.isArray(res) ? res : []);
|
|
199
|
+
for (const r of rows) {
|
|
200
|
+
const val = Object.values(r || {})[0];
|
|
201
|
+
if (val) tableNamesSet.add(val);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
// If raw query fails, fall back to known tables
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Always include model tables and known system tables
|
|
209
|
+
for (const m of modelList) {
|
|
210
|
+
if (m.table) tableNamesSet.add(m.table);
|
|
211
|
+
}
|
|
212
|
+
const knownSystemTables = [
|
|
213
|
+
'admin_users',
|
|
214
|
+
'admin_activity_logs',
|
|
215
|
+
'audit_logs',
|
|
216
|
+
'email_logs',
|
|
217
|
+
'webspresso_jobs',
|
|
218
|
+
'analytics_page_views',
|
|
219
|
+
'analytics_sessions',
|
|
220
|
+
'knex_migrations',
|
|
221
|
+
];
|
|
222
|
+
for (const t of knownSystemTables) {
|
|
223
|
+
tableNamesSet.add(t);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const tables = [];
|
|
227
|
+
let totalRows = 0;
|
|
228
|
+
let hasAttention = false;
|
|
229
|
+
|
|
230
|
+
for (const table of tableNamesSet) {
|
|
231
|
+
try {
|
|
232
|
+
const exists = await db.knex.schema.hasTable(table);
|
|
233
|
+
if (!exists) continue;
|
|
234
|
+
|
|
235
|
+
const countRes = await db.knex(table).count('* as count').first();
|
|
236
|
+
const countVal = countRes ? (countRes.count ?? countRes['count(*)'] ?? countRes['COUNT(*)'] ?? 0) : 0;
|
|
237
|
+
const count = parseInt(countVal, 10) || 0;
|
|
238
|
+
totalRows += count;
|
|
239
|
+
|
|
240
|
+
let category = 'system';
|
|
241
|
+
const model = modelByTable.get(table);
|
|
242
|
+
if (model) {
|
|
243
|
+
category = 'orm';
|
|
244
|
+
} else if (table === 'audit_logs' || table === 'email_logs' || table === 'admin_activity_logs') {
|
|
245
|
+
category = 'logs';
|
|
246
|
+
} else if (table.startsWith('analytics_')) {
|
|
247
|
+
category = 'analytics';
|
|
248
|
+
} else if (table.includes('jobs') || table.includes('queue')) {
|
|
249
|
+
category = 'queue';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Attention flag if logs or queue tables exceed 25,000 rows
|
|
253
|
+
const isHighVolume = (category === 'logs' || category === 'analytics' || category === 'queue') && count > 25000;
|
|
254
|
+
if (isHighVolume) hasAttention = true;
|
|
255
|
+
|
|
256
|
+
tables.push({
|
|
257
|
+
table,
|
|
258
|
+
count,
|
|
259
|
+
category,
|
|
260
|
+
modelName: model ? model.name : null,
|
|
261
|
+
modelLabel: model?.admin?.label || (model ? model.name : null),
|
|
262
|
+
modelIcon: model?.admin?.icon || null,
|
|
263
|
+
isHighVolume,
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// Table query error, ignore
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Sort tables by row count descending
|
|
271
|
+
tables.sort((a, b) => b.count - a.count);
|
|
272
|
+
|
|
273
|
+
// SQLite storage stats if available
|
|
274
|
+
let sqliteInfo = null;
|
|
275
|
+
if (isSqlite) {
|
|
276
|
+
try {
|
|
277
|
+
const pageCountRes = await db.knex.raw('PRAGMA page_count;');
|
|
278
|
+
const pageSizeRes = await db.knex.raw('PRAGMA page_size;');
|
|
279
|
+
const freelistRes = await db.knex.raw('PRAGMA freelist_count;');
|
|
280
|
+
|
|
281
|
+
const pageCount = (pageCountRes[0]?.page_count ?? pageCountRes[0]?.[0]) || 0;
|
|
282
|
+
const pageSize = (pageSizeRes[0]?.page_size ?? pageSizeRes[0]?.[0]) || 4096;
|
|
283
|
+
const freelistCount = (freelistRes[0]?.freelist_count ?? freelistRes[0]?.[0]) || 0;
|
|
284
|
+
|
|
285
|
+
const sizeBytes = pageCount * pageSize;
|
|
286
|
+
const freeBytes = freelistCount * pageSize;
|
|
287
|
+
const fragmentation = pageCount > 0 ? ((freelistCount / pageCount) * 100).toFixed(1) : 0;
|
|
288
|
+
|
|
289
|
+
sqliteInfo = {
|
|
290
|
+
pageCount,
|
|
291
|
+
pageSize,
|
|
292
|
+
freelistCount,
|
|
293
|
+
sizeBytes,
|
|
294
|
+
freeBytes,
|
|
295
|
+
fragmentation: Number(fragmentation),
|
|
296
|
+
};
|
|
297
|
+
} catch (e) {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
engine: isSqlite ? 'SQLite' : isPostgres ? 'PostgreSQL' : isMysql ? 'MySQL' : clientName,
|
|
302
|
+
totalTables: tables.length,
|
|
303
|
+
totalRows,
|
|
304
|
+
overallHealth: hasAttention ? 'attention' : 'healthy',
|
|
305
|
+
tables,
|
|
306
|
+
sqliteInfo,
|
|
307
|
+
};
|
|
138
308
|
}
|
|
139
309
|
|
|
140
310
|
/**
|
|
@@ -299,6 +469,118 @@ const WidgetRenderers = {
|
|
|
299
469
|
},
|
|
300
470
|
},
|
|
301
471
|
|
|
472
|
+
// Database Storage & Health Widget
|
|
473
|
+
'database-storage': {
|
|
474
|
+
render: (data) => {
|
|
475
|
+
if (!data) return m('div.text-gray-500.dark:text-slate-400', 'No data');
|
|
476
|
+
const totalRows = data.totalRows || 0;
|
|
477
|
+
const tables = data.tables || [];
|
|
478
|
+
const isAttention = data.overallHealth === 'attention';
|
|
479
|
+
|
|
480
|
+
function formatStorageBytes(bytes) {
|
|
481
|
+
if (!bytes || bytes <= 0) return '0 B';
|
|
482
|
+
const k = 1024;
|
|
483
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
484
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
485
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const categoryStyles = {
|
|
489
|
+
orm: { bg: 'bg-blue-100 dark:bg-blue-950/60', text: 'text-blue-700 dark:text-blue-300', bar: 'bg-blue-600' },
|
|
490
|
+
logs: { bg: 'bg-amber-100 dark:bg-amber-950/60', text: 'text-amber-800 dark:text-amber-300', bar: 'bg-amber-500' },
|
|
491
|
+
analytics: { bg: 'bg-purple-100 dark:bg-purple-950/60', text: 'text-purple-700 dark:text-purple-300', bar: 'bg-purple-600' },
|
|
492
|
+
queue: { bg: 'bg-rose-100 dark:bg-rose-950/60', text: 'text-rose-700 dark:text-rose-300', bar: 'bg-rose-500' },
|
|
493
|
+
system: { bg: 'bg-gray-100 dark:bg-slate-800', text: 'text-gray-700 dark:text-slate-300', bar: 'bg-gray-500' },
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
return m('div.space-y-4', [
|
|
497
|
+
// 4 Metric Tiles
|
|
498
|
+
m('div.grid.grid-cols-2.sm:grid-cols-4.gap-3', [
|
|
499
|
+
m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
|
|
500
|
+
m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Database Engine'),
|
|
501
|
+
m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', data.engine || 'SQL'),
|
|
502
|
+
data.sqliteInfo && data.sqliteInfo.sizeBytes > 0
|
|
503
|
+
? m('p.text-xs.text-blue-600.dark:text-blue-400.mt-0.5', formatStorageBytes(data.sqliteInfo.sizeBytes))
|
|
504
|
+
: null,
|
|
505
|
+
]),
|
|
506
|
+
m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
|
|
507
|
+
m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Total Tables'),
|
|
508
|
+
m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', data.totalTables || 0),
|
|
509
|
+
m('p.text-xs.text-gray-400.dark:text-slate-500.mt-0.5', 'Active in schema'),
|
|
510
|
+
]),
|
|
511
|
+
m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
|
|
512
|
+
m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Total Records'),
|
|
513
|
+
m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', totalRows.toLocaleString()),
|
|
514
|
+
m('p.text-xs.text-gray-400.dark:text-slate-500.mt-0.5', 'Across all tables'),
|
|
515
|
+
]),
|
|
516
|
+
m('div.p-3.rounded-xl.border', {
|
|
517
|
+
class: isAttention
|
|
518
|
+
? 'bg-amber-50.border-amber-200.dark:bg-amber-950/40.dark:border-amber-800/80'
|
|
519
|
+
: 'bg-emerald-50.border-emerald-200.dark:bg-emerald-950/40.dark:border-emerald-800/80'
|
|
520
|
+
}, [
|
|
521
|
+
m('p.text-xs.font-medium', {
|
|
522
|
+
class: isAttention ? 'text-amber-700 dark:text-amber-300' : 'text-emerald-700 dark:text-emerald-300'
|
|
523
|
+
}, 'Storage Health'),
|
|
524
|
+
m('div.flex.items-center.gap-1.5.mt-1', [
|
|
525
|
+
m('span.w-2.5.h-2.5.rounded-full', {
|
|
526
|
+
class: isAttention ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'
|
|
527
|
+
}),
|
|
528
|
+
m('p.text-lg.font-bold', {
|
|
529
|
+
class: isAttention ? 'text-amber-800 dark:text-amber-200' : 'text-emerald-800 dark:text-emerald-200'
|
|
530
|
+
}, isAttention ? 'Attention' : 'Healthy'),
|
|
531
|
+
]),
|
|
532
|
+
m('p.text-xs.mt-0.5', {
|
|
533
|
+
class: isAttention ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'
|
|
534
|
+
}, isAttention ? 'High row count in logs' : 'No table bloat detected'),
|
|
535
|
+
]),
|
|
536
|
+
]),
|
|
537
|
+
|
|
538
|
+
// Table Breakdown List with Progress Bars
|
|
539
|
+
m('div.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800.p-4', [
|
|
540
|
+
m('div.flex.items-center.justify-between.mb-3', [
|
|
541
|
+
m('h4.text-xs.font-bold.uppercase.tracking-wider.text-gray-500.dark:text-slate-400', 'Top Tables by Row Count'),
|
|
542
|
+
m('span.text-xs.text-gray-400.dark:text-slate-500', tables.length + ' tables'),
|
|
543
|
+
]),
|
|
544
|
+
tables.length === 0
|
|
545
|
+
? m('p.text-xs.text-gray-400.text-center.py-4', 'No tables detected')
|
|
546
|
+
: m('div.space-y-2.5', tables.slice(0, 8).map(t => {
|
|
547
|
+
const pct = totalRows > 0 ? ((t.count / totalRows) * 100).toFixed(1) : 0;
|
|
548
|
+
const style = categoryStyles[t.category] || categoryStyles.system;
|
|
549
|
+
|
|
550
|
+
return m('div.flex.flex-col.gap-1', [
|
|
551
|
+
m('div.flex.items-center.justify-between.text-xs', [
|
|
552
|
+
m('div.flex.items-center.gap-2', [
|
|
553
|
+
t.modelName
|
|
554
|
+
? m('a.font-semibold.text-blue-600.dark:text-blue-400.hover:underline', {
|
|
555
|
+
href: '/models/' + t.modelName,
|
|
556
|
+
onclick: (e) => { e.preventDefault(); m.route.set('/models/' + t.modelName); }
|
|
557
|
+
}, (t.modelIcon ? t.modelIcon + ' ' : '') + t.table)
|
|
558
|
+
: m('span.font-medium.text-gray-800.dark:text-slate-200', t.table),
|
|
559
|
+
m('span.px-1.5.py-0.5.rounded.text-[10px].font-medium', {
|
|
560
|
+
class: style.bg + ' ' + style.text
|
|
561
|
+
}, t.category.toUpperCase()),
|
|
562
|
+
t.isHighVolume
|
|
563
|
+
? m('span.text-[10px].text-amber-600.dark:text-amber-400.font-medium', '⚠️ High volume')
|
|
564
|
+
: null,
|
|
565
|
+
]),
|
|
566
|
+
m('div.flex.items-center.gap-2', [
|
|
567
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-100', t.count.toLocaleString() + ' rows'),
|
|
568
|
+
m('span.text-gray-400.dark:text-slate-500.w-10.text-right', pct + '%'),
|
|
569
|
+
])
|
|
570
|
+
]),
|
|
571
|
+
m('div.w-full.bg-gray-200.dark:bg-slate-700.rounded-full.h-1.5.overflow-hidden', [
|
|
572
|
+
m('div.h-1.5.rounded-full.transition-all', {
|
|
573
|
+
class: style.bar,
|
|
574
|
+
style: { width: Math.max(1, Math.min(100, Number(pct))) + '%' }
|
|
575
|
+
})
|
|
576
|
+
])
|
|
577
|
+
]);
|
|
578
|
+
}))
|
|
579
|
+
])
|
|
580
|
+
]);
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
|
|
302
584
|
// Default renderer
|
|
303
585
|
default: {
|
|
304
586
|
render: (data) => {
|
|
@@ -473,4 +755,5 @@ const Dashboard = {
|
|
|
473
755
|
module.exports = {
|
|
474
756
|
registerDashboardWidgets,
|
|
475
757
|
generateDashboardComponent,
|
|
758
|
+
collectDatabaseStorageStats,
|
|
476
759
|
};
|
|
@@ -10,32 +10,68 @@
|
|
|
10
10
|
* @param {Object} options.registry - Admin registry
|
|
11
11
|
* @param {Object} options.db - Database instance
|
|
12
12
|
*/
|
|
13
|
-
function registerModelMenuItems(options) {
|
|
14
|
-
|
|
13
|
+
function registerModelMenuItems(options, maybeModels) {
|
|
14
|
+
let registry = options?.registry;
|
|
15
|
+
let db = options?.db;
|
|
16
|
+
let models = options?.models;
|
|
17
|
+
|
|
18
|
+
if (!registry && options && typeof options.registerMenuItem === 'function') {
|
|
19
|
+
registry = options;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(maybeModels)) {
|
|
22
|
+
models = maybeModels;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
const { getAllModels } = require('../../../core/orm/model');
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
let modelList;
|
|
28
|
+
if (Array.isArray(models)) {
|
|
29
|
+
modelList = models;
|
|
30
|
+
} else if (db && typeof db.getAllModels === 'function') {
|
|
31
|
+
const res = db.getAllModels();
|
|
32
|
+
modelList = Array.isArray(res) ? res : Array.from(res.values());
|
|
33
|
+
} else if (getAllModels) {
|
|
34
|
+
modelList = Array.from(getAllModels().values());
|
|
35
|
+
} else {
|
|
36
|
+
modelList = [];
|
|
37
|
+
}
|
|
20
38
|
|
|
21
39
|
// Create "Content" menu group for models
|
|
22
|
-
registry.registerMenuGroup
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
if (registry && typeof registry.registerMenuGroup === 'function') {
|
|
41
|
+
registry.registerMenuGroup('content', {
|
|
42
|
+
label: 'Content',
|
|
43
|
+
icon: 'database',
|
|
44
|
+
order: 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
27
47
|
|
|
28
48
|
// Register menu items for each model
|
|
29
49
|
for (const model of modelList) {
|
|
30
50
|
if (model.admin?.enabled) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
let group = 'content';
|
|
52
|
+
if (model.admin.group) {
|
|
53
|
+
group = String(model.admin.group).toLowerCase().trim().replace(/\s+/g, '-');
|
|
54
|
+
const hasGroup = registry.menuGroups && typeof registry.menuGroups.has === 'function'
|
|
55
|
+
? registry.menuGroups.has(group)
|
|
56
|
+
: false;
|
|
57
|
+
if (!hasGroup && registry && typeof registry.registerMenuGroup === 'function') {
|
|
58
|
+
registry.registerMenuGroup(group, {
|
|
59
|
+
label: model.admin.group,
|
|
60
|
+
icon: 'database',
|
|
61
|
+
order: model.admin.groupOrder || 50,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (registry && typeof registry.registerMenuItem === 'function') {
|
|
66
|
+
registry.registerMenuItem({
|
|
67
|
+
id: `model-${model.name}`,
|
|
68
|
+
label: model.admin.label || model.name,
|
|
69
|
+
path: `/models/${model.name}`,
|
|
70
|
+
icon: model.admin.icon || 'table',
|
|
71
|
+
group,
|
|
72
|
+
order: model.admin.menuOrder || 10,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
39
75
|
}
|
|
40
76
|
}
|
|
41
77
|
}
|
|
@@ -481,6 +517,8 @@ const Layout = {
|
|
|
481
517
|
m(Sidebar),
|
|
482
518
|
m('main.lg:ml-64.p-6.pt-20.lg:pt-6', vnode.children),
|
|
483
519
|
m(VersionUpdateToast),
|
|
520
|
+
typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
|
|
521
|
+
typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
|
|
484
522
|
]);
|
|
485
523
|
},
|
|
486
524
|
};
|
|
@@ -191,7 +191,10 @@ function registerUserManagement(options) {
|
|
|
191
191
|
models: modelName,
|
|
192
192
|
visible: (record) => !record[fieldMap.active],
|
|
193
193
|
handler: async (record, model, { db }) => {
|
|
194
|
-
const
|
|
194
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
195
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
196
|
+
: String(model);
|
|
197
|
+
const repo = db.getRepository(targetModel);
|
|
195
198
|
await repo.update(record.id, { [fieldMap.active]: true });
|
|
196
199
|
return { message: 'User activated' };
|
|
197
200
|
},
|
|
@@ -204,7 +207,10 @@ function registerUserManagement(options) {
|
|
|
204
207
|
models: modelName,
|
|
205
208
|
visible: (record) => record[fieldMap.active],
|
|
206
209
|
handler: async (record, model, { db }) => {
|
|
207
|
-
const
|
|
210
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
211
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
212
|
+
: String(model);
|
|
213
|
+
const repo = db.getRepository(targetModel);
|
|
208
214
|
await repo.update(record.id, { [fieldMap.active]: false });
|
|
209
215
|
return { message: 'User deactivated' };
|
|
210
216
|
},
|
|
@@ -218,7 +224,10 @@ function registerUserManagement(options) {
|
|
|
218
224
|
confirm: true,
|
|
219
225
|
confirmMessage: 'This will generate a new random password. Continue?',
|
|
220
226
|
handler: async (record, model, { db }) => {
|
|
221
|
-
const
|
|
227
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
228
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
229
|
+
: String(model);
|
|
230
|
+
const repo = db.getRepository(targetModel);
|
|
222
231
|
// Generate random password
|
|
223
232
|
const newPassword = generateRandomPassword(12);
|
|
224
233
|
const hashedPassword = await hash(newPassword);
|
|
@@ -234,7 +243,10 @@ function registerUserManagement(options) {
|
|
|
234
243
|
color: 'green',
|
|
235
244
|
models: modelName,
|
|
236
245
|
handler: async (records, model, { db }) => {
|
|
237
|
-
const
|
|
246
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
247
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
248
|
+
: String(model);
|
|
249
|
+
const repo = db.getRepository(targetModel);
|
|
238
250
|
for (const record of records) {
|
|
239
251
|
await repo.update(record.id, { [fieldMap.active]: true });
|
|
240
252
|
}
|
|
@@ -248,7 +260,10 @@ function registerUserManagement(options) {
|
|
|
248
260
|
color: 'red',
|
|
249
261
|
models: modelName,
|
|
250
262
|
handler: async (records, model, { db }) => {
|
|
251
|
-
const
|
|
263
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
264
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
265
|
+
: String(model);
|
|
266
|
+
const repo = db.getRepository(targetModel);
|
|
252
267
|
for (const record of records) {
|
|
253
268
|
await repo.update(record.id, { [fieldMap.active]: false });
|
|
254
269
|
}
|
|
@@ -264,7 +279,10 @@ function registerUserManagement(options) {
|
|
|
264
279
|
confirm: true,
|
|
265
280
|
confirmMessage: 'Are you sure you want to delete the selected users? This cannot be undone.',
|
|
266
281
|
handler: async (records, model, { db }) => {
|
|
267
|
-
const
|
|
282
|
+
const targetModel = typeof model === 'object' && model !== null
|
|
283
|
+
? (model.name || model.modelName || model.model || String(model))
|
|
284
|
+
: String(model);
|
|
285
|
+
const repo = db.getRepository(targetModel);
|
|
268
286
|
for (const record of records) {
|
|
269
287
|
await repo.delete(record.id);
|
|
270
288
|
}
|
|
@@ -78,6 +78,17 @@ function generateAuditLogComponent(options = {}) {
|
|
|
78
78
|
m('button.bg-blue-600.text-white.px-4.py-1.rounded', {
|
|
79
79
|
onclick: function() { page = 1; load(); },
|
|
80
80
|
}, 'Apply'),
|
|
81
|
+
m('button.border.px-3.py-1.rounded.text-red-700.ml-auto', {
|
|
82
|
+
onclick: function() {
|
|
83
|
+
if (!confirm('Delete audit logs older than 90 days?')) return;
|
|
84
|
+
api.post(API + '/purge', { days: 90 }).then(function() {
|
|
85
|
+
page = 1;
|
|
86
|
+
load();
|
|
87
|
+
}).catch(function(e) {
|
|
88
|
+
alert('Purge failed: ' + (e.message || String(e)));
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
}, 'Purge 90d'),
|
|
81
92
|
]),
|
|
82
93
|
m('div.text-sm.text-gray-500', 'Total: ' + total),
|
|
83
94
|
m('div.overflow-x-auto.border.rounded', [
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* @module plugins/audit-log/api-handlers
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { purgeAuditLogs } = require('./purge');
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* @param {Object} options
|
|
8
10
|
* @param {import('knex').Knex} options.knex
|
|
@@ -51,6 +53,37 @@ function createAuditLogHandlers(options) {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
async function purgeHandler(req, res) {
|
|
57
|
+
try {
|
|
58
|
+
const body = req.body || {};
|
|
59
|
+
const query = req.query || {};
|
|
60
|
+
const rawDays = body.days !== undefined
|
|
61
|
+
? body.days
|
|
62
|
+
: (body.retentionDays !== undefined ? body.retentionDays : (query.days !== undefined ? query.days : 90));
|
|
63
|
+
const days = parseInt(rawDays, 10);
|
|
64
|
+
if (Number.isNaN(days) || days < 1) {
|
|
65
|
+
return res.status(400).json({ error: 'days must be a positive integer' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const olderThan = body.olderThan
|
|
69
|
+
? new Date(body.olderThan)
|
|
70
|
+
: new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
71
|
+
|
|
72
|
+
if (Number.isNaN(olderThan.getTime())) {
|
|
73
|
+
return res.status(400).json({ error: 'invalid olderThan date' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const deleted = await purgeAuditLogs(knex, {
|
|
77
|
+
tableName,
|
|
78
|
+
olderThan,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
res.json({ ok: true, success: true, deleted, olderThan: olderThan.toISOString() });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
res.status(500).json({ error: err.message });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
/**
|
|
55
88
|
* Programmatic list (for other plugins)
|
|
56
89
|
* @param {Object} [filters]
|
|
@@ -74,6 +107,7 @@ function createAuditLogHandlers(options) {
|
|
|
74
107
|
|
|
75
108
|
return {
|
|
76
109
|
listHandler,
|
|
110
|
+
purgeHandler,
|
|
77
111
|
queryLogs,
|
|
78
112
|
};
|
|
79
113
|
}
|