@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>SaaSBackend Command Center</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
|
|
10
|
+
<style>
|
|
11
|
+
.stat-card { @apply bg-white p-5 rounded-xl border border-gray-200 shadow-sm transition-all hover:shadow-md flex flex-col justify-between; }
|
|
12
|
+
.category-title { @apply text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4 flex items-center gap-2; }
|
|
13
|
+
.chart-container { position: relative; height: 250px; width: 100%; }
|
|
14
|
+
.status-badge { @apply px-2 py-0.5 rounded-full text-[10px] font-bold uppercase; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body class="bg-gray-50 min-h-screen p-6">
|
|
18
|
+
<div id="app" class="max-w-7xl mx-auto space-y-8">
|
|
19
|
+
<!-- Header -->
|
|
20
|
+
<div class="flex justify-between items-center">
|
|
21
|
+
<div>
|
|
22
|
+
<h1 class="text-2xl font-bold text-gray-900">Command Center</h1>
|
|
23
|
+
<p class="text-sm text-gray-500">System-wide health and growth metrics.</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-3">
|
|
26
|
+
<div id="system-health-badge" class="flex items-center gap-2 text-xs font-semibold"></div>
|
|
27
|
+
<button onclick="fetchStats()" class="p-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors shadow-sm">
|
|
28
|
+
<i class="ti ti-refresh text-gray-600"></i>
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- Categorized Stats Grid -->
|
|
34
|
+
<div class="space-y-8">
|
|
35
|
+
<!-- Section: User Management -->
|
|
36
|
+
<div>
|
|
37
|
+
<h2 class="category-title"><i class="ti ti-users"></i> User Management</h2>
|
|
38
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
39
|
+
<div class="stat-card">
|
|
40
|
+
<span class="text-sm text-gray-500">Total Users</span>
|
|
41
|
+
<div class="flex items-end justify-between mt-2">
|
|
42
|
+
<h3 id="users-total" class="text-2xl font-bold">-</h3>
|
|
43
|
+
<span id="users-new" class="text-xs font-medium text-green-600 bg-green-50 px-2 py-1 rounded">+0 today</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="stat-card">
|
|
47
|
+
<span class="text-sm text-gray-500">Organizations</span>
|
|
48
|
+
<h3 id="users-orgs" class="text-2xl font-bold mt-2">-</h3>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="stat-card">
|
|
51
|
+
<span class="text-sm text-gray-500">Active Invites</span>
|
|
52
|
+
<h3 id="users-invites" class="text-2xl font-bold mt-2">-</h3>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="stat-card">
|
|
55
|
+
<span class="text-sm text-gray-500">Conversion (est)</span>
|
|
56
|
+
<h3 class="text-2xl font-bold mt-2 text-blue-600">84.2%</h3>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Section: Monitoring & Health -->
|
|
62
|
+
<div>
|
|
63
|
+
<h2 class="category-title"><i class="ti ti-activity"></i> System Monitoring</h2>
|
|
64
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
65
|
+
<div class="stat-card border-red-100 bg-red-50/10">
|
|
66
|
+
<span class="text-sm text-gray-500">Unresolved Errors</span>
|
|
67
|
+
<h3 id="mon-errors" class="text-2xl font-bold text-red-600 mt-2">-</h3>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="stat-card">
|
|
70
|
+
<span class="text-sm text-gray-500">Audit Events (24h)</span>
|
|
71
|
+
<h3 id="mon-audit" class="text-2xl font-bold mt-2">-</h3>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="stat-card">
|
|
74
|
+
<span class="text-sm text-gray-500">Emails Sent</span>
|
|
75
|
+
<h3 id="mon-emails-sent" class="text-2xl font-bold text-green-600 mt-2">-</h3>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="stat-card">
|
|
78
|
+
<span class="text-sm text-gray-500">Emails Failed</span>
|
|
79
|
+
<h3 id="mon-emails-failed" class="text-2xl font-bold text-orange-600 mt-2">-</h3>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- Section: Content & Configuration -->
|
|
85
|
+
<div>
|
|
86
|
+
<h2 class="category-title"><i class="ti ti-folder"></i> Content & Configuration</h2>
|
|
87
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
88
|
+
<div class="stat-card">
|
|
89
|
+
<span class="text-sm text-gray-500">Total Assets</span>
|
|
90
|
+
<h3 id="cont-assets" class="text-2xl font-bold mt-2">-</h3>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="stat-card">
|
|
93
|
+
<span class="text-sm text-gray-500">Virtual EJS Files</span>
|
|
94
|
+
<h3 id="cont-ejs" class="text-2xl font-bold mt-2">-</h3>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="stat-card">
|
|
97
|
+
<span class="text-sm text-gray-500">JSON Configs</span>
|
|
98
|
+
<h3 id="cont-json" class="text-2xl font-bold mt-2">-</h3>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="stat-card">
|
|
101
|
+
<span class="text-sm text-gray-500">Active Plans</span>
|
|
102
|
+
<h3 id="saas-plans" class="text-2xl font-bold mt-2">-</h3>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- Charts Row -->
|
|
109
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
110
|
+
<div class="lg:col-span-2 stat-card">
|
|
111
|
+
<h3 class="text-sm font-bold text-gray-900 mb-6">User Growth & Activity (7d)</h3>
|
|
112
|
+
<div class="chart-container">
|
|
113
|
+
<canvas id="growthChart"></canvas>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="stat-card">
|
|
117
|
+
<h3 class="text-sm font-bold text-gray-900 mb-6">Email Delivery Trend</h3>
|
|
118
|
+
<div class="chart-container">
|
|
119
|
+
<canvas id="emailChart"></canvas>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Recent Activity Table -->
|
|
125
|
+
<div class="stat-card overflow-hidden">
|
|
126
|
+
<div class="flex items-center justify-between mb-4">
|
|
127
|
+
<h3 class="text-sm font-bold text-gray-900">Latest Audit Logs</h3>
|
|
128
|
+
<a href="#" onclick="parent.window.app.openTab({id:'audit', label:'Audit Logs', path:'/admin/audit', icon:'ti-history'})" class="text-xs text-blue-600 hover:underline">View All</a>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="overflow-x-auto">
|
|
131
|
+
<table class="w-full text-left">
|
|
132
|
+
<thead class="bg-gray-50 text-[10px] font-bold text-gray-400 uppercase">
|
|
133
|
+
<tr>
|
|
134
|
+
<th class="px-4 py-2">Action</th>
|
|
135
|
+
<th class="px-4 py-2">Actor</th>
|
|
136
|
+
<th class="px-4 py-2">Details</th>
|
|
137
|
+
<th class="px-4 py-2">Time</th>
|
|
138
|
+
</tr>
|
|
139
|
+
</thead>
|
|
140
|
+
<tbody id="activity-list" class="divide-y divide-gray-100">
|
|
141
|
+
<!-- Dynamically populated -->
|
|
142
|
+
</tbody>
|
|
143
|
+
</table>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<%- include('partials/footer') %>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<script>
|
|
151
|
+
let growthChart, emailChart;
|
|
152
|
+
|
|
153
|
+
async function fetchStats() {
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch('<%= baseUrl %>/api/admin/stats/overview');
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
updateUI(data);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('Failed to load stats:', err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function updateUI(data) {
|
|
164
|
+
const { categories, recentActivity, timeSeries } = data;
|
|
165
|
+
|
|
166
|
+
const setVal = (id, val) => {
|
|
167
|
+
const el = document.getElementById(id);
|
|
168
|
+
if (el) el.innerText = val;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Update Health Badge
|
|
172
|
+
const healthBadge = document.getElementById('system-health-badge');
|
|
173
|
+
if (healthBadge) {
|
|
174
|
+
const health = categories.monitoring.health;
|
|
175
|
+
const healthMeta = {
|
|
176
|
+
healthy: { cls: 'bg-green-100 text-green-700', icon: 'ti ti-heartbeat', label: 'Healthy' },
|
|
177
|
+
warning: { cls: 'bg-yellow-100 text-yellow-700', icon: 'ti ti-alert-triangle', label: 'Warnings present' },
|
|
178
|
+
critical: { cls: 'bg-red-100 text-red-700', icon: 'ti ti-flame', label: 'Critical issues' }
|
|
179
|
+
};
|
|
180
|
+
const meta = healthMeta[health] || healthMeta.healthy;
|
|
181
|
+
healthBadge.innerHTML = `<span class="status-badge ${meta.cls}" title="${meta.label}"><i class="${meta.icon}"></i></span>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Update Users
|
|
185
|
+
setVal('users-total', categories.users.total);
|
|
186
|
+
setVal('users-new', `+${categories.users.newToday} today`);
|
|
187
|
+
setVal('users-orgs', categories.users.orgs);
|
|
188
|
+
setVal('users-invites', categories.users.invites);
|
|
189
|
+
|
|
190
|
+
// Update Monitoring
|
|
191
|
+
setVal('mon-errors', categories.monitoring.errors);
|
|
192
|
+
setVal('mon-audit', categories.monitoring.audit24h);
|
|
193
|
+
setVal('mon-emails-sent', categories.monitoring.emailsSent);
|
|
194
|
+
setVal('mon-emails-failed', categories.monitoring.emailsFailed);
|
|
195
|
+
|
|
196
|
+
// Update Content
|
|
197
|
+
setVal('cont-assets', categories.content.assets);
|
|
198
|
+
setVal('cont-ejs', categories.content.virtualEjs);
|
|
199
|
+
setVal('saas-workflows', categories.content.workflows || 0);
|
|
200
|
+
|
|
201
|
+
const activityBody = document.getElementById('activity-list');
|
|
202
|
+
if (activityBody) {
|
|
203
|
+
activityBody.innerHTML = recentActivity.map(event => `
|
|
204
|
+
<tr class="text-xs text-gray-700">
|
|
205
|
+
<td class="px-4 py-3"><span class="font-medium text-gray-900">${event.action}</span></td>
|
|
206
|
+
<td class="px-4 py-3">${event.actorEmail || 'System'}</td>
|
|
207
|
+
<td class="px-4 py-3 truncate max-w-[200px] text-gray-400">${event.resourceId || '-'}</td>
|
|
208
|
+
<td class="px-4 py-3 text-gray-400">${new Date(event.createdAt).toLocaleTimeString()}</td>
|
|
209
|
+
</tr>
|
|
210
|
+
`).join('');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Update Charts
|
|
214
|
+
updateCharts(timeSeries);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function updateCharts(timeSeries) {
|
|
218
|
+
const labels = timeSeries.map(d => d.date);
|
|
219
|
+
|
|
220
|
+
// Growth & Activity Chart
|
|
221
|
+
if (growthChart) growthChart.destroy();
|
|
222
|
+
growthChart = new Chart(document.getElementById('growthChart'), {
|
|
223
|
+
type: 'line',
|
|
224
|
+
data: {
|
|
225
|
+
labels,
|
|
226
|
+
datasets: [
|
|
227
|
+
{
|
|
228
|
+
label: 'Signups',
|
|
229
|
+
data: timeSeries.map(d => d.users),
|
|
230
|
+
borderColor: 'rgb(59, 130, 246)',
|
|
231
|
+
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
232
|
+
fill: true,
|
|
233
|
+
tension: 0.4
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
label: 'Activity',
|
|
237
|
+
data: timeSeries.map(d => d.activity),
|
|
238
|
+
borderColor: 'rgb(139, 92, 246)',
|
|
239
|
+
backgroundColor: 'transparent',
|
|
240
|
+
tension: 0.4
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
options: {
|
|
245
|
+
responsive: true,
|
|
246
|
+
maintainAspectRatio: false,
|
|
247
|
+
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } },
|
|
248
|
+
scales: {
|
|
249
|
+
y: { grid: { display: false } },
|
|
250
|
+
x: { grid: { display: false } }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Email Delivery Chart
|
|
256
|
+
if (emailChart) emailChart.destroy();
|
|
257
|
+
emailChart = new Chart(document.getElementById('emailChart'), {
|
|
258
|
+
type: 'bar',
|
|
259
|
+
data: {
|
|
260
|
+
labels,
|
|
261
|
+
datasets: [{
|
|
262
|
+
label: 'Sent Emails',
|
|
263
|
+
data: timeSeries.map(d => d.emails),
|
|
264
|
+
backgroundColor: 'rgba(34, 197, 94, 0.8)',
|
|
265
|
+
borderRadius: 4
|
|
266
|
+
}]
|
|
267
|
+
},
|
|
268
|
+
options: {
|
|
269
|
+
responsive: true,
|
|
270
|
+
maintainAspectRatio: false,
|
|
271
|
+
plugins: { legend: { display: false } },
|
|
272
|
+
scales: {
|
|
273
|
+
y: { grid: { display: false }, ticks: { stepSize: 1 } },
|
|
274
|
+
x: { grid: { display: false } }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
window.onload = fetchStats;
|
|
281
|
+
</script>
|
|
282
|
+
<script>
|
|
283
|
+
window.addEventListener("keydown", (e) => {
|
|
284
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
</script>
|
|
290
|
+
</body>
|
|
291
|
+
</html>
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Superbackend Admin Dashboard</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
|
|
10
|
+
<style>
|
|
11
|
+
[v-cloak] { display: none; }
|
|
12
|
+
.sidebar-link.active { @apply bg-blue-50 text-blue-700 border-r-4 border-blue-700; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body class="bg-gray-50 overflow-hidden">
|
|
16
|
+
<div id="app" class="h-screen flex flex-col" v-cloak>
|
|
17
|
+
<!-- Top Header -->
|
|
18
|
+
<header class="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 shrink-0">
|
|
19
|
+
<div class="flex items-center gap-4">
|
|
20
|
+
<i class="ti ti-layout-dashboard text-2xl text-blue-600"></i>
|
|
21
|
+
<h1 class="text-xl font-bold text-gray-800">
|
|
22
|
+
Superbackend <span class="text-xs font-normal text-gray-500 ml-2 align-middle">(saasbackend)</span>
|
|
23
|
+
</h1>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-6">
|
|
26
|
+
<div class="hidden md:flex items-center gap-2 text-xs text-gray-400 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-200">
|
|
27
|
+
<kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded shadow-sm font-sans">Ctrl</kbd>
|
|
28
|
+
<span>+</span>
|
|
29
|
+
<kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded shadow-sm font-sans">K</kbd>
|
|
30
|
+
<span class="ml-1">to search</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="flex items-center gap-4">
|
|
33
|
+
<span class="text-sm text-gray-500">v1.0.0</span>
|
|
34
|
+
<a :href="baseUrl + adminBase + '/api/test'" target="_blank" class="text-sm text-blue-600 hover:underline">API Test</a>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</header>
|
|
38
|
+
|
|
39
|
+
<div class="flex-1 flex overflow-hidden">
|
|
40
|
+
<%- include('partials/dashboard/sidebar') %>
|
|
41
|
+
|
|
42
|
+
<!-- Main Content Area -->
|
|
43
|
+
<main class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
|
44
|
+
<%- include('partials/dashboard/tab-bar') %>
|
|
45
|
+
|
|
46
|
+
<div class="flex-1 relative">
|
|
47
|
+
<!-- Iframes -->
|
|
48
|
+
<iframe
|
|
49
|
+
v-for="tab in tabs"
|
|
50
|
+
v-show="activeTabId === tab.id"
|
|
51
|
+
:key="tab.id"
|
|
52
|
+
:src="baseUrl + tab.path"
|
|
53
|
+
class="absolute inset-0 w-full h-full border-none"
|
|
54
|
+
:id="'frame-' + tab.id"
|
|
55
|
+
></iframe>
|
|
56
|
+
|
|
57
|
+
<!-- Empty State -->
|
|
58
|
+
<div v-if="tabs.length === 0" class="flex items-center justify-center h-full text-gray-400">
|
|
59
|
+
<div class="text-center">
|
|
60
|
+
<i class="ti ti-layout-dashboard text-6xl mb-4 opacity-20"></i>
|
|
61
|
+
<p class="text-lg font-medium">No open modules</p>
|
|
62
|
+
<p class="text-sm opacity-60">Select a module from the sidebar or press Ctrl+K</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</main>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<%- include('partials/dashboard/palette') %>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
// Initialize globals from EJS locals first (must run before nav-items)
|
|
74
|
+
window.BASE_URL = '<%= baseUrl %>';
|
|
75
|
+
window.ADMIN_PATH = '<%= adminPath %>';
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<%- include('partials/dashboard/nav-items') %>
|
|
79
|
+
|
|
80
|
+
<script>
|
|
81
|
+
const { createApp, ref, computed, nextTick, onMounted, onUnmounted, watch } = Vue;
|
|
82
|
+
|
|
83
|
+
createApp({
|
|
84
|
+
setup() {
|
|
85
|
+
const baseUrl = window.BASE_URL;
|
|
86
|
+
const adminBase = window.ADMIN_PATH || '/admin';
|
|
87
|
+
const navSections = window.NAV_SECTIONS || [];
|
|
88
|
+
|
|
89
|
+
// Tabs state
|
|
90
|
+
const tabs = ref([]);
|
|
91
|
+
const activeTabId = ref(null);
|
|
92
|
+
|
|
93
|
+
// localStorage utilities
|
|
94
|
+
const STORAGE_KEY = 'adminDashboardTabs';
|
|
95
|
+
|
|
96
|
+
const saveTabsToStorage = () => {
|
|
97
|
+
try {
|
|
98
|
+
const data = {
|
|
99
|
+
tabs: tabs.value,
|
|
100
|
+
activeTabId: activeTabId.value
|
|
101
|
+
};
|
|
102
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.warn('Failed to save tabs to localStorage:', error);
|
|
105
|
+
}
|
|
106
|
+
// Always save to URL as fallback
|
|
107
|
+
saveTabsToURL();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const loadTabsFromStorage = () => {
|
|
111
|
+
// Try localStorage first
|
|
112
|
+
try {
|
|
113
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
114
|
+
if (stored) {
|
|
115
|
+
const data = JSON.parse(stored);
|
|
116
|
+
if (data && Array.isArray(data.tabs)) {
|
|
117
|
+
// Validate tabs have required fields
|
|
118
|
+
const validTabs = data.tabs.filter(tab =>
|
|
119
|
+
tab &&
|
|
120
|
+
typeof tab.id === 'string' &&
|
|
121
|
+
typeof tab.label === 'string' &&
|
|
122
|
+
typeof tab.path === 'string'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Ensure tabs still exist in available modules
|
|
126
|
+
const availableModuleIds = allModules.value.map(m => m.id);
|
|
127
|
+
const existingTabs = validTabs.filter(tab =>
|
|
128
|
+
availableModuleIds.includes(tab.id)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (existingTabs.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
tabs: existingTabs,
|
|
134
|
+
activeTabId: existingTabs.some(t => t.id === data.activeTabId)
|
|
135
|
+
? data.activeTabId
|
|
136
|
+
: (existingTabs.length > 0 ? existingTabs[0].id : null)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.warn('Failed to load tabs from localStorage:', error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback to URL parameters
|
|
146
|
+
return loadTabsFromURL();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// URL utilities for fallback persistence
|
|
150
|
+
let urlUpdateTimeout = null;
|
|
151
|
+
|
|
152
|
+
const saveTabsToURL = () => {
|
|
153
|
+
clearTimeout(urlUpdateTimeout);
|
|
154
|
+
urlUpdateTimeout = setTimeout(() => {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(window.location);
|
|
157
|
+
|
|
158
|
+
if (tabs.value.length > 0) {
|
|
159
|
+
const tabIds = tabs.value.map(t => t.id).join(',');
|
|
160
|
+
url.searchParams.set('openTabs', tabIds);
|
|
161
|
+
if (activeTabId.value) {
|
|
162
|
+
url.searchParams.set('activeTab', activeTabId.value);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
url.searchParams.delete('openTabs');
|
|
166
|
+
url.searchParams.delete('activeTab');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Update URL without page reload
|
|
170
|
+
window.history.replaceState({}, '', url);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.warn('Failed to save tabs to URL:', error);
|
|
173
|
+
}
|
|
174
|
+
}, 300); // Debounce URL updates
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const loadTabsFromURL = () => {
|
|
178
|
+
try {
|
|
179
|
+
const url = new URL(window.location);
|
|
180
|
+
const openTabsParam = url.searchParams.get('openTabs');
|
|
181
|
+
const activeTabParam = url.searchParams.get('activeTab');
|
|
182
|
+
|
|
183
|
+
if (!openTabsParam) return null;
|
|
184
|
+
|
|
185
|
+
// Parse comma-separated tab IDs
|
|
186
|
+
const tabIds = openTabsParam.split(',').filter(id => id.trim());
|
|
187
|
+
if (tabIds.length === 0) return null;
|
|
188
|
+
|
|
189
|
+
// Get available modules
|
|
190
|
+
const availableModules = allModules.value;
|
|
191
|
+
|
|
192
|
+
// Reconstruct full tab objects from IDs
|
|
193
|
+
const tabs = tabIds.map(id => {
|
|
194
|
+
const module = availableModules.find(m => m.id === id);
|
|
195
|
+
if (module) {
|
|
196
|
+
return {
|
|
197
|
+
id: module.id,
|
|
198
|
+
label: module.label,
|
|
199
|
+
icon: module.icon,
|
|
200
|
+
path: module.path
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}).filter(tab => tab !== null);
|
|
205
|
+
|
|
206
|
+
if (tabs.length === 0) return null;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
tabs,
|
|
210
|
+
activeTabId: tabs.some(t => t.id === activeTabParam)
|
|
211
|
+
? activeTabParam
|
|
212
|
+
: tabs[0].id
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.warn('Failed to load tabs from URL:', error);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Palette state
|
|
221
|
+
const showPalette = ref(false);
|
|
222
|
+
const paletteQuery = ref('');
|
|
223
|
+
const paletteCursor = ref(0);
|
|
224
|
+
const paletteInput = ref(null);
|
|
225
|
+
|
|
226
|
+
// Flattened modules for search
|
|
227
|
+
const allModules = computed(() => {
|
|
228
|
+
const modules = [];
|
|
229
|
+
if (Array.isArray(navSections)) {
|
|
230
|
+
navSections.forEach(section => {
|
|
231
|
+
if (section && Array.isArray(section.items)) {
|
|
232
|
+
section.items.forEach(item => {
|
|
233
|
+
modules.push({
|
|
234
|
+
...item,
|
|
235
|
+
sectionTitle: section.title || 'Other'
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return modules;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const filteredModules = computed(() => {
|
|
245
|
+
const mods = allModules.value;
|
|
246
|
+
if (!paletteQuery.value) return mods;
|
|
247
|
+
const query = paletteQuery.value.toLowerCase();
|
|
248
|
+
return mods.filter(m =>
|
|
249
|
+
(m.label && m.label.toLowerCase().includes(query)) ||
|
|
250
|
+
(m.sectionTitle && m.sectionTitle.toLowerCase().includes(query))
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Tab methods
|
|
255
|
+
const openTab = (item) => {
|
|
256
|
+
if (!item || !item.id) return;
|
|
257
|
+
const existing = tabs.value.find(t => t.id === item.id);
|
|
258
|
+
if (!existing) {
|
|
259
|
+
tabs.value.push({
|
|
260
|
+
id: item.id,
|
|
261
|
+
label: item.label,
|
|
262
|
+
icon: item.icon,
|
|
263
|
+
path: item.path
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
activeTabId.value = item.id;
|
|
267
|
+
saveTabsToStorage();
|
|
268
|
+
closePalette();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const closeTab = (id) => {
|
|
272
|
+
const index = tabs.value.findIndex(t => t.id === id);
|
|
273
|
+
if (index === -1) return;
|
|
274
|
+
|
|
275
|
+
const wasActive = activeTabId.value === id;
|
|
276
|
+
tabs.value.splice(index, 1);
|
|
277
|
+
|
|
278
|
+
if (wasActive && tabs.value.length > 0) {
|
|
279
|
+
const nextTab = tabs.value[Math.min(index, tabs.value.length - 1)];
|
|
280
|
+
activeTabId.value = nextTab.id;
|
|
281
|
+
} else if (tabs.value.length === 0) {
|
|
282
|
+
activeTabId.value = null;
|
|
283
|
+
}
|
|
284
|
+
saveTabsToStorage();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Palette methods
|
|
288
|
+
const togglePalette = () => {
|
|
289
|
+
showPalette.value = !showPalette.value;
|
|
290
|
+
if (showPalette.value) {
|
|
291
|
+
paletteQuery.value = '';
|
|
292
|
+
paletteCursor.value = 0;
|
|
293
|
+
nextTick(() => {
|
|
294
|
+
if (paletteInput.value) paletteInput.value.focus();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const closePalette = () => {
|
|
300
|
+
showPalette.value = false;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const navigatePalette = (dir) => {
|
|
304
|
+
const len = filteredModules.value.length;
|
|
305
|
+
if (len === 0) return;
|
|
306
|
+
paletteCursor.value = (paletteCursor.value + dir + len) % len;
|
|
307
|
+
|
|
308
|
+
nextTick(() => {
|
|
309
|
+
const items = document.querySelectorAll('.palette-item');
|
|
310
|
+
const el = items[paletteCursor.value];
|
|
311
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const selectPaletteItem = () => {
|
|
316
|
+
const item = filteredModules.value[paletteCursor.value];
|
|
317
|
+
if (item) openTab(item);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const selectModule = (item) => {
|
|
321
|
+
openTab(item);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Keyboard events
|
|
325
|
+
const handleKeydown = (e) => {
|
|
326
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
togglePalette();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const handleMessage = (e) => {
|
|
333
|
+
if (e.data && e.data.type === 'keydown' && e.data.ctrlK) {
|
|
334
|
+
togglePalette();
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
onMounted(() => {
|
|
339
|
+
window.addEventListener('keydown', handleKeydown);
|
|
340
|
+
window.addEventListener('message', handleMessage);
|
|
341
|
+
|
|
342
|
+
// Load saved tabs from localStorage
|
|
343
|
+
const savedState = loadTabsFromStorage();
|
|
344
|
+
if (savedState && savedState.tabs.length > 0) {
|
|
345
|
+
tabs.value = savedState.tabs;
|
|
346
|
+
activeTabId.value = savedState.activeTabId;
|
|
347
|
+
} else {
|
|
348
|
+
// Open default tab if no saved state
|
|
349
|
+
if (allModules.value.length > 0) {
|
|
350
|
+
const defaultModule = allModules.value.find(m => m.id === 'overview') || allModules.value[0];
|
|
351
|
+
if (defaultModule) openTab(defaultModule);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Watch for activeTabId changes (when user clicks on tabs)
|
|
357
|
+
watch(activeTabId, () => {
|
|
358
|
+
saveTabsToStorage();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
onUnmounted(() => {
|
|
362
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
363
|
+
window.removeEventListener('message', handleMessage);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
baseUrl,
|
|
368
|
+
adminBase,
|
|
369
|
+
navSections,
|
|
370
|
+
tabs,
|
|
371
|
+
activeTabId,
|
|
372
|
+
openTab,
|
|
373
|
+
closeTab,
|
|
374
|
+
showPalette,
|
|
375
|
+
paletteQuery,
|
|
376
|
+
paletteCursor,
|
|
377
|
+
paletteInput,
|
|
378
|
+
filteredModules,
|
|
379
|
+
togglePalette,
|
|
380
|
+
closePalette,
|
|
381
|
+
navigatePalette,
|
|
382
|
+
selectPaletteItem,
|
|
383
|
+
selectModule
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}).mount('#app');
|
|
387
|
+
</script>
|
|
388
|
+
<script>
|
|
389
|
+
window.addEventListener("keydown", (e) => {
|
|
390
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
</script>
|
|
396
|
+
</body>
|
|
397
|
+
</html>
|