@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,280 @@
|
|
|
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>EJS Virtual Codebase - Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-gray-100">
|
|
10
|
+
<div class="min-h-screen">
|
|
11
|
+
<div class="bg-white shadow">
|
|
12
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
13
|
+
<div class="flex justify-between items-center">
|
|
14
|
+
<div>
|
|
15
|
+
<h1 class="text-2xl font-bold text-gray-900">EJS Virtual Codebase</h1>
|
|
16
|
+
<p class="text-sm text-gray-600 mt-1">DB overrides + Vibe coding + History/Rollback + Cache control.</p>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="flex items-center gap-4">
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-4">
|
|
25
|
+
<div class="bg-white rounded-lg shadow p-4">
|
|
26
|
+
<div class="flex flex-wrap gap-3 items-end">
|
|
27
|
+
<div class="flex-1 min-w-[260px]">
|
|
28
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">File</label>
|
|
29
|
+
<select id="fileSelect" class="w-full border rounded px-2 py-2 text-sm"></select>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="min-w-[200px]">
|
|
33
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">LLM Provider Key</label>
|
|
34
|
+
<input id="providerKey" class="w-full border rounded px-2 py-2 text-sm" placeholder="e.g. openrouter" />
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="min-w-[260px]">
|
|
38
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Model</label>
|
|
39
|
+
<input id="model" class="w-full border rounded px-2 py-2 text-sm" placeholder="e.g. x-ai/grok-code-fast-1" />
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="flex items-center gap-2">
|
|
43
|
+
<button id="btnReload" class="px-3 py-2 bg-gray-200 text-gray-900 rounded text-sm">Reload</button>
|
|
44
|
+
<button id="btnClearCache" class="px-3 py-2 bg-orange-600 text-white rounded text-sm">Clear Cache</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
50
|
+
<div class="bg-white rounded-lg shadow p-4">
|
|
51
|
+
<div class="flex items-center justify-between mb-2">
|
|
52
|
+
<h2 class="font-semibold text-gray-900">Editor</h2>
|
|
53
|
+
<div class="flex gap-2">
|
|
54
|
+
<button id="btnSave" class="px-3 py-1.5 bg-blue-600 text-white rounded text-sm">Save Override</button>
|
|
55
|
+
<button id="btnRevert" class="px-3 py-1.5 bg-gray-800 text-white rounded text-sm">Revert to Default</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<textarea id="editor" class="w-full h-[520px] border rounded p-2 font-mono text-xs"></textarea>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="space-y-4">
|
|
62
|
+
<div class="bg-white rounded-lg shadow p-4">
|
|
63
|
+
<h2 class="font-semibold text-gray-900 mb-2">Vibe coding</h2>
|
|
64
|
+
<textarea id="prompt" class="w-full h-28 border rounded p-2 text-sm" placeholder="Describe the change you want..."></textarea>
|
|
65
|
+
<div class="mt-2 flex justify-end gap-2">
|
|
66
|
+
<button id="btnVibe" class="px-3 py-2 bg-purple-600 text-white rounded text-sm">Run Vibe Edit</button>
|
|
67
|
+
</div>
|
|
68
|
+
<div id="vibeResult" class="mt-3 text-xs text-gray-700 whitespace-pre-wrap"></div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="bg-white rounded-lg shadow p-4">
|
|
72
|
+
<h2 class="font-semibold text-gray-900 mb-2">History (latest 50)</h2>
|
|
73
|
+
<div id="history" class="space-y-2 max-h-[320px] overflow-auto"></div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<script>
|
|
81
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
|
|
82
|
+
|
|
83
|
+
const STORAGE_KEYS = {
|
|
84
|
+
providerKey: 'ejsVirtual.providerKey',
|
|
85
|
+
model: 'ejsVirtual.model',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let files = [];
|
|
89
|
+
let selectedPath = null;
|
|
90
|
+
|
|
91
|
+
function escapeHtml(str) {
|
|
92
|
+
return String(str ?? '')
|
|
93
|
+
.replace(/&/g, '&')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/"/g, '"')
|
|
97
|
+
.replace(/'/g, ''');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function showMessage(el, msg) {
|
|
101
|
+
el.textContent = msg;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadSettings() {
|
|
105
|
+
const pk = localStorage.getItem(STORAGE_KEYS.providerKey) || '';
|
|
106
|
+
const model = localStorage.getItem(STORAGE_KEYS.model) || '';
|
|
107
|
+
document.getElementById('providerKey').value = pk;
|
|
108
|
+
document.getElementById('model').value = model;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function saveSettings() {
|
|
112
|
+
localStorage.setItem(STORAGE_KEYS.providerKey, document.getElementById('providerKey').value.trim());
|
|
113
|
+
localStorage.setItem(STORAGE_KEYS.model, document.getElementById('model').value.trim());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function fetchFiles() {
|
|
117
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/files`);
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to list files');
|
|
120
|
+
files = data.items || [];
|
|
121
|
+
const select = document.getElementById('fileSelect');
|
|
122
|
+
select.innerHTML = '';
|
|
123
|
+
files.forEach((f) => {
|
|
124
|
+
const opt = document.createElement('option');
|
|
125
|
+
opt.value = f.path;
|
|
126
|
+
opt.textContent = `${f.path}${f.enabled ? ' (override)' : ''}${f.integrated ? ' [integrated]' : ''}`;
|
|
127
|
+
select.appendChild(opt);
|
|
128
|
+
});
|
|
129
|
+
if (!selectedPath && files.length) {
|
|
130
|
+
selectedPath = files[0].path;
|
|
131
|
+
}
|
|
132
|
+
select.value = selectedPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function loadFile(p) {
|
|
136
|
+
selectedPath = p;
|
|
137
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(p)}`);
|
|
138
|
+
const data = await res.json();
|
|
139
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load file');
|
|
140
|
+
const editor = document.getElementById('editor');
|
|
141
|
+
editor.value = (data.db && data.db.enabled) ? (data.db.content || '') : (data.fs.content || '');
|
|
142
|
+
await loadHistory(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function loadHistory(p) {
|
|
146
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/history?path=${encodeURIComponent(p)}`);
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
const container = document.getElementById('history');
|
|
149
|
+
container.innerHTML = '';
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
container.innerHTML = `<div class="text-xs text-red-600">${escapeHtml(data?.error || 'Failed to load history')}</div>`;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
(data.versions || []).forEach((v) => {
|
|
155
|
+
const row = document.createElement('div');
|
|
156
|
+
row.className = 'border rounded p-2 text-xs';
|
|
157
|
+
row.innerHTML = `
|
|
158
|
+
<div class="flex justify-between gap-2">
|
|
159
|
+
<div>
|
|
160
|
+
<div class="font-semibold">${escapeHtml(v.source || '')}</div>
|
|
161
|
+
<div class="text-gray-500">${escapeHtml(new Date(v.createdAt).toLocaleString())}</div>
|
|
162
|
+
<div class="text-gray-700">${escapeHtml(v.description || '')}</div>
|
|
163
|
+
</div>
|
|
164
|
+
<button class="px-2 py-1 bg-gray-100 rounded" data-version-id="${escapeHtml(v._id)}">Rollback</button>
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
row.querySelector('button').onclick = () => rollback(v._id);
|
|
168
|
+
container.appendChild(row);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function saveOverride() {
|
|
173
|
+
if (!selectedPath) return;
|
|
174
|
+
const content = document.getElementById('editor').value;
|
|
175
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(selectedPath)}`,
|
|
176
|
+
{
|
|
177
|
+
method: 'PUT',
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({ content, enabled: true, description: 'Manual save' }),
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to save');
|
|
184
|
+
await fetchFiles();
|
|
185
|
+
await loadFile(selectedPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function revertToDefault() {
|
|
189
|
+
if (!selectedPath) return;
|
|
190
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file/revert?path=${encodeURIComponent(selectedPath)}`,
|
|
191
|
+
{ method: 'POST' }
|
|
192
|
+
);
|
|
193
|
+
const data = await res.json();
|
|
194
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to revert');
|
|
195
|
+
await fetchFiles();
|
|
196
|
+
await loadFile(selectedPath);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function rollback(versionId) {
|
|
200
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/rollback`,
|
|
201
|
+
{
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'Content-Type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({ versionId }),
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
const data = await res.json();
|
|
208
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to rollback');
|
|
209
|
+
await fetchFiles();
|
|
210
|
+
await loadFile(selectedPath);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function runVibe() {
|
|
214
|
+
if (!selectedPath) return;
|
|
215
|
+
saveSettings();
|
|
216
|
+
const prompt = document.getElementById('prompt').value;
|
|
217
|
+
const providerKey = document.getElementById('providerKey').value.trim();
|
|
218
|
+
const model = document.getElementById('model').value.trim();
|
|
219
|
+
|
|
220
|
+
const out = document.getElementById('vibeResult');
|
|
221
|
+
showMessage(out, 'Running...');
|
|
222
|
+
|
|
223
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/vibe`,
|
|
224
|
+
{
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({ prompt, paths: [selectedPath], providerKey, model }),
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
if (!res.ok) {
|
|
232
|
+
showMessage(out, data?.error || 'Vibe failed');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
showMessage(out, `Applied via provider=${data.providerKey} model=${data.model}\nGroup=${data.group?.title || ''}`);
|
|
236
|
+
await fetchFiles();
|
|
237
|
+
await loadFile(selectedPath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function clearCache() {
|
|
241
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/cache/clear`, { method: 'POST' });
|
|
242
|
+
const data = await res.json();
|
|
243
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to clear cache');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function bootstrap() {
|
|
247
|
+
loadSettings();
|
|
248
|
+
await fetchFiles();
|
|
249
|
+
const select = document.getElementById('fileSelect');
|
|
250
|
+
select.onchange = async () => {
|
|
251
|
+
await loadFile(select.value);
|
|
252
|
+
};
|
|
253
|
+
document.getElementById('btnReload').onclick = async () => {
|
|
254
|
+
await fetchFiles();
|
|
255
|
+
await loadFile(selectedPath);
|
|
256
|
+
};
|
|
257
|
+
document.getElementById('btnSave').onclick = saveOverride;
|
|
258
|
+
document.getElementById('btnRevert').onclick = revertToDefault;
|
|
259
|
+
document.getElementById('btnVibe').onclick = runVibe;
|
|
260
|
+
document.getElementById('btnClearCache').onclick = clearCache;
|
|
261
|
+
|
|
262
|
+
if (selectedPath) {
|
|
263
|
+
await loadFile(selectedPath);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
bootstrap().catch((err) => {
|
|
268
|
+
document.body.innerHTML = `<pre class="p-6 text-red-600">${escapeHtml(err.message || String(err))}</pre>`;
|
|
269
|
+
});
|
|
270
|
+
</script>
|
|
271
|
+
<script>
|
|
272
|
+
window.addEventListener("keydown", (e) => {
|
|
273
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
</script>
|
|
279
|
+
</body>
|
|
280
|
+
</html>
|
|
@@ -0,0 +1,368 @@
|
|
|
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>Error Tracking - Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-gray-100">
|
|
10
|
+
<div class="min-h-screen">
|
|
11
|
+
<div class="bg-white shadow">
|
|
12
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
13
|
+
<div class="flex justify-between items-center">
|
|
14
|
+
<div>
|
|
15
|
+
<h1 class="text-2xl font-bold text-gray-900">Error Tracking</h1>
|
|
16
|
+
<p class="text-sm text-gray-600 mt-1">Aggregated errors from frontend and backend</p>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="flex items-center gap-4">
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
25
|
+
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
|
26
|
+
<div class="flex gap-4 text-sm">
|
|
27
|
+
<div class="bg-white border border-gray-200 rounded-lg px-4 py-2">
|
|
28
|
+
<span class="text-gray-500">Open:</span>
|
|
29
|
+
<span id="stat-open" class="text-orange-600 font-semibold ml-1">—</span>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="bg-white border border-gray-200 rounded-lg px-4 py-2">
|
|
32
|
+
<span class="text-gray-500">Last 24h:</span>
|
|
33
|
+
<span id="stat-24h" class="text-blue-600 font-semibold ml-1">—</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<button onclick="refreshAll()" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Refresh</button>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<details class="bg-white border border-gray-200 rounded-xl p-4 mb-6">
|
|
40
|
+
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-900">Quick integration (browser SDK)</summary>
|
|
41
|
+
<div class="mt-4 space-y-4">
|
|
42
|
+
<div>
|
|
43
|
+
<div class="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Embed SDK</div>
|
|
44
|
+
<div class="relative">
|
|
45
|
+
<pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-embed"><script src="<%= baseUrl %>/api/error-tracking/browser-sdk"></script></code></pre>
|
|
46
|
+
<button type="button" class="absolute top-2 right-2 text-xs px-2 py-1 bg-white border border-gray-200 rounded hover:border-blue-500" onclick="copySnippet('snippet-embed')">Copy</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div>
|
|
51
|
+
<div class="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Identify user (JWT header)</div>
|
|
52
|
+
<div class="relative">
|
|
53
|
+
<pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-jwt">saasbackend.errorTracking.config({
|
|
54
|
+
headers: { authorization: "Bearer XXX" }
|
|
55
|
+
})</code></pre>
|
|
56
|
+
<button type="button" class="absolute top-2 right-2 text-xs px-2 py-1 bg-white border border-gray-200 rounded hover:border-blue-500" onclick="copySnippet('snippet-jwt')">Copy</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div>
|
|
61
|
+
<div class="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Future npm package (bundlers)</div>
|
|
62
|
+
<div class="relative">
|
|
63
|
+
<pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-npm">import { createErrorTrackingClient } from '@saasbackend/sdk/error-tracking/browser';
|
|
64
|
+
|
|
65
|
+
const client = createErrorTrackingClient({
|
|
66
|
+
endpoint: '/api/log/error',
|
|
67
|
+
headers: { authorization: `Bearer ${token}` },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
client.init();</code></pre>
|
|
71
|
+
<button type="button" class="absolute top-2 right-2 text-xs px-2 py-1 bg-white border border-gray-200 rounded hover:border-blue-500" onclick="copySnippet('snippet-npm')">Copy</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
<div class="bg-white border border-gray-200 rounded-xl p-4 mb-6">
|
|
78
|
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-3">
|
|
79
|
+
<select id="filter-source" class="border rounded px-3 py-2 text-sm">
|
|
80
|
+
<option value="">All Sources</option>
|
|
81
|
+
<option value="frontend">Frontend</option>
|
|
82
|
+
<option value="backend">Backend</option>
|
|
83
|
+
</select>
|
|
84
|
+
<select id="filter-severity" class="border rounded px-3 py-2 text-sm">
|
|
85
|
+
<option value="">All Severities</option>
|
|
86
|
+
<option value="fatal">Fatal</option>
|
|
87
|
+
<option value="error">Error</option>
|
|
88
|
+
<option value="warn">Warning</option>
|
|
89
|
+
<option value="info">Info</option>
|
|
90
|
+
</select>
|
|
91
|
+
<select id="filter-status" class="border rounded px-3 py-2 text-sm">
|
|
92
|
+
<option value="">All Status</option>
|
|
93
|
+
<option value="open">Open</option>
|
|
94
|
+
<option value="ignored">Ignored</option>
|
|
95
|
+
<option value="resolved">Resolved</option>
|
|
96
|
+
</select>
|
|
97
|
+
<select id="filter-since" class="border rounded px-3 py-2 text-sm">
|
|
98
|
+
<option value="">All Time</option>
|
|
99
|
+
<option value="1">Last 24 hours</option>
|
|
100
|
+
<option value="7">Last 7 days</option>
|
|
101
|
+
<option value="30">Last 30 days</option>
|
|
102
|
+
</select>
|
|
103
|
+
<input id="filter-search" type="text" placeholder="Search..." class="border rounded px-3 py-2 text-sm" />
|
|
104
|
+
</div>
|
|
105
|
+
<div class="mt-3 flex justify-end">
|
|
106
|
+
<button id="btn-filter" class="bg-gray-900 text-white px-4 py-2 rounded hover:bg-gray-800 text-sm">Filter</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
111
|
+
<table class="w-full">
|
|
112
|
+
<thead class="bg-gray-50">
|
|
113
|
+
<tr class="text-left text-xs uppercase tracking-wide text-gray-500">
|
|
114
|
+
<th class="px-4 py-3">Error</th>
|
|
115
|
+
<th class="px-4 py-3 w-24">Source</th>
|
|
116
|
+
<th class="px-4 py-3 w-24">Severity</th>
|
|
117
|
+
<th class="px-4 py-3 w-24 text-right">Count</th>
|
|
118
|
+
<th class="px-4 py-3 w-48">Last Seen</th>
|
|
119
|
+
<th class="px-4 py-3 w-24">Status</th>
|
|
120
|
+
<th class="px-4 py-3 w-24"></th>
|
|
121
|
+
</tr>
|
|
122
|
+
</thead>
|
|
123
|
+
<tbody id="errors-table" class="divide-y divide-gray-100">
|
|
124
|
+
<tr><td colspan="7" class="px-4 py-8 text-center text-gray-500">Loading...</td></tr>
|
|
125
|
+
</tbody>
|
|
126
|
+
</table>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
|
130
|
+
<div id="pagination-info"></div>
|
|
131
|
+
<div class="flex gap-2">
|
|
132
|
+
<button id="btn-prev" class="px-3 py-1 bg-white border border-gray-300 rounded hover:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">Prev</button>
|
|
133
|
+
<button id="btn-next" class="px-3 py-1 bg-white border border-gray-300 rounded hover:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div id="modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4">
|
|
140
|
+
<div class="bg-white rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
141
|
+
<div class="flex items-center justify-between px-6 py-4 border-b">
|
|
142
|
+
<h2 id="modal-title" class="text-xl font-bold text-gray-900">Error Details</h2>
|
|
143
|
+
<button id="modal-close" class="text-gray-500 hover:text-gray-800">Close</button>
|
|
144
|
+
</div>
|
|
145
|
+
<div id="modal-content" class="flex-1 overflow-y-auto p-6"></div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<script>
|
|
150
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
|
|
151
|
+
let currentPage = 1;
|
|
152
|
+
const pageSize = 20;
|
|
153
|
+
let totalPages = 1;
|
|
154
|
+
|
|
155
|
+
async function copySnippet(id) {
|
|
156
|
+
try {
|
|
157
|
+
const el = document.getElementById(id);
|
|
158
|
+
const text = el ? (el.textContent || '') : '';
|
|
159
|
+
if (!text) return;
|
|
160
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
161
|
+
await navigator.clipboard.writeText(text);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ta = document.createElement('textarea');
|
|
166
|
+
ta.value = text;
|
|
167
|
+
ta.style.position = 'fixed';
|
|
168
|
+
ta.style.left = '-9999px';
|
|
169
|
+
document.body.appendChild(ta);
|
|
170
|
+
ta.focus();
|
|
171
|
+
ta.select();
|
|
172
|
+
document.execCommand('copy');
|
|
173
|
+
document.body.removeChild(ta);
|
|
174
|
+
} catch (e) {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function loadStats() {
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(`${API_BASE}/api/admin/errors/stats`);
|
|
180
|
+
if (!res.ok) return;
|
|
181
|
+
const stats = await res.json();
|
|
182
|
+
document.getElementById('stat-open').textContent = stats.open ?? 0;
|
|
183
|
+
document.getElementById('stat-24h').textContent = stats.last24h ?? 0;
|
|
184
|
+
} catch (e) {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadErrors() {
|
|
188
|
+
const source = document.getElementById('filter-source').value;
|
|
189
|
+
const severity = document.getElementById('filter-severity').value;
|
|
190
|
+
const status = document.getElementById('filter-status').value;
|
|
191
|
+
const sinceDays = document.getElementById('filter-since').value;
|
|
192
|
+
const q = document.getElementById('filter-search').value;
|
|
193
|
+
|
|
194
|
+
const params = new URLSearchParams();
|
|
195
|
+
if (source) params.set('source', source);
|
|
196
|
+
if (severity) params.set('severity', severity);
|
|
197
|
+
if (status) params.set('status', status);
|
|
198
|
+
if (q) params.set('q', q);
|
|
199
|
+
if (sinceDays) {
|
|
200
|
+
const since = new Date(Date.now() - parseInt(sinceDays) * 24 * 60 * 60 * 1000).toISOString();
|
|
201
|
+
params.set('since', since);
|
|
202
|
+
}
|
|
203
|
+
params.set('page', currentPage);
|
|
204
|
+
params.set('pageSize', pageSize);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const res = await fetch(`${API_BASE}/api/admin/errors?` + params.toString());
|
|
208
|
+
if (!res.ok) throw new Error('Failed to load errors');
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
totalPages = data.totalPages || 1;
|
|
211
|
+
renderErrors(data.errors || []);
|
|
212
|
+
renderPagination(data);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
document.getElementById('errors-table').innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-red-600">Failed to load errors</td></tr>';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderErrors(errors) {
|
|
219
|
+
const tbody = document.getElementById('errors-table');
|
|
220
|
+
if (!errors.length) {
|
|
221
|
+
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-500">No errors found</td></tr>';
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
tbody.innerHTML = errors.map(err => {
|
|
226
|
+
const sourceBadge = err.source === 'frontend'
|
|
227
|
+
? '<span class="px-2 py-1 text-xs rounded bg-purple-100 text-purple-700">frontend</span>'
|
|
228
|
+
: '<span class="px-2 py-1 text-xs rounded bg-blue-100 text-blue-700">backend</span>';
|
|
229
|
+
|
|
230
|
+
const statusColor = err.status === 'open' ? 'text-orange-600' : (err.status === 'resolved' ? 'text-green-600' : 'text-gray-500');
|
|
231
|
+
|
|
232
|
+
return `
|
|
233
|
+
<tr class="hover:bg-gray-50 cursor-pointer" onclick="showDetails('${err._id}')">
|
|
234
|
+
<td class="px-4 py-3">
|
|
235
|
+
<div class="text-gray-900 font-medium">${escapeHtml(err.errorName || 'Error')}</div>
|
|
236
|
+
<div class="text-gray-500 text-sm truncate max-w-xl">${escapeHtml(err.messageTemplate || '')}</div>
|
|
237
|
+
</td>
|
|
238
|
+
<td class="px-4 py-3">${sourceBadge}</td>
|
|
239
|
+
<td class="px-4 py-3 text-sm text-gray-700">${escapeHtml(err.severity)}</td>
|
|
240
|
+
<td class="px-4 py-3 text-right font-mono text-gray-900">${(err.countTotal || 0).toLocaleString()}</td>
|
|
241
|
+
<td class="px-4 py-3 text-sm text-gray-600">${formatDate(err.lastSeenAt)}</td>
|
|
242
|
+
<td class="px-4 py-3 text-sm ${statusColor}">${escapeHtml(err.status)}</td>
|
|
243
|
+
<td class="px-4 py-3"><button onclick="event.stopPropagation(); showDetails('${err._id}')" class="text-blue-600 hover:underline text-sm">View</button></td>
|
|
244
|
+
</tr>
|
|
245
|
+
`;
|
|
246
|
+
}).join('');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderPagination(data) {
|
|
250
|
+
document.getElementById('pagination-info').textContent = `Page ${data.page} of ${data.totalPages} (${data.total} total)`;
|
|
251
|
+
document.getElementById('btn-prev').disabled = data.page <= 1;
|
|
252
|
+
document.getElementById('btn-next').disabled = data.page >= data.totalPages;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function showDetails(id) {
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(`${API_BASE}/api/admin/errors/${id}`);
|
|
258
|
+
if (!res.ok) throw new Error('Failed to load details');
|
|
259
|
+
const err = await res.json();
|
|
260
|
+
renderModal(err);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
alert('Failed to load error details');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderModal(err) {
|
|
267
|
+
document.getElementById('modal-title').textContent = err.errorName || 'Error Details';
|
|
268
|
+
const samples = (err.samples || []).slice().reverse();
|
|
269
|
+
|
|
270
|
+
document.getElementById('modal-content').innerHTML = `
|
|
271
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6 text-sm">
|
|
272
|
+
<div><span class="text-gray-500">Source:</span> <span class="ml-2 text-gray-900">${escapeHtml(err.source)}</span></div>
|
|
273
|
+
<div><span class="text-gray-500">Severity:</span> <span class="ml-2 text-gray-900">${escapeHtml(err.severity)}</span></div>
|
|
274
|
+
<div><span class="text-gray-500">Status:</span> <span class="ml-2 text-gray-900">${escapeHtml(err.status)}</span></div>
|
|
275
|
+
<div><span class="text-gray-500">Total Count:</span> <span class="ml-2 text-gray-900">${(err.countTotal || 0).toLocaleString()}</span></div>
|
|
276
|
+
<div><span class="text-gray-500">First Seen:</span> <span class="ml-2 text-gray-900">${formatDate(err.firstSeenAt)}</span></div>
|
|
277
|
+
<div><span class="text-gray-500">Last Seen:</span> <span class="ml-2 text-gray-900">${formatDate(err.lastSeenAt)}</span></div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div class="mb-4">
|
|
281
|
+
<div class="text-gray-500 text-sm">Message Template</div>
|
|
282
|
+
<div class="mt-1 p-3 bg-gray-50 rounded font-mono text-xs overflow-x-auto">${escapeHtml(err.messageTemplate || '')}</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div class="mb-6 flex gap-2">
|
|
286
|
+
<button onclick="updateStatus('${err._id}', 'open')" class="px-3 py-1 text-sm rounded ${err.status === 'open' ? 'bg-orange-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Open</button>
|
|
287
|
+
<button onclick="updateStatus('${err._id}', 'ignored')" class="px-3 py-1 text-sm rounded ${err.status === 'ignored' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Ignored</button>
|
|
288
|
+
<button onclick="updateStatus('${err._id}', 'resolved')" class="px-3 py-1 text-sm rounded ${err.status === 'resolved' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Resolved</button>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<h3 class="text-lg font-bold text-gray-900 mb-3">Recent Samples (${samples.length})</h3>
|
|
292
|
+
<div class="space-y-4">
|
|
293
|
+
${samples.map(s => `
|
|
294
|
+
<div class="border border-gray-200 rounded-lg p-4">
|
|
295
|
+
<div class="flex justify-between items-start mb-2">
|
|
296
|
+
<span class="text-xs text-gray-500">${formatDate(s.at)}</span>
|
|
297
|
+
<span class="text-xs text-gray-500">${escapeHtml(s.actor?.ip || 'Unknown IP')}</span>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="text-sm text-gray-900 mb-2">${escapeHtml(s.message || '')}</div>
|
|
300
|
+
${s.stack ? `<details class="text-xs"><summary class="text-blue-600 cursor-pointer">Stack trace</summary><pre class="mt-2 p-2 bg-gray-50 rounded overflow-x-auto text-gray-700">${escapeHtml(s.stack)}</pre></details>` : ''}
|
|
301
|
+
${s.request?.path ? `<div class="text-xs text-gray-500 mt-2">${escapeHtml(s.request.method || 'GET')} ${escapeHtml(s.request.path)} ${s.request.statusCode ? '→ ' + s.request.statusCode : ''}</div>` : ''}
|
|
302
|
+
</div>
|
|
303
|
+
`).join('')}
|
|
304
|
+
</div>
|
|
305
|
+
`;
|
|
306
|
+
|
|
307
|
+
document.getElementById('modal').classList.remove('hidden');
|
|
308
|
+
document.getElementById('modal').classList.add('flex');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function updateStatus(id, status) {
|
|
312
|
+
try {
|
|
313
|
+
const res = await fetch(`${API_BASE}/api/admin/errors/${id}/status`, {
|
|
314
|
+
method: 'PUT',
|
|
315
|
+
headers: { 'Content-Type': 'application/json' },
|
|
316
|
+
body: JSON.stringify({ status }),
|
|
317
|
+
});
|
|
318
|
+
if (!res.ok) return;
|
|
319
|
+
const err = await res.json();
|
|
320
|
+
renderModal(err);
|
|
321
|
+
await loadErrors();
|
|
322
|
+
await loadStats();
|
|
323
|
+
} catch (e) {
|
|
324
|
+
alert('Failed to update status');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function closeModal() {
|
|
329
|
+
document.getElementById('modal').classList.add('hidden');
|
|
330
|
+
document.getElementById('modal').classList.remove('flex');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function escapeHtml(str) {
|
|
334
|
+
const div = document.createElement('div');
|
|
335
|
+
div.textContent = str || '';
|
|
336
|
+
return div.innerHTML;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatDate(dateStr) {
|
|
340
|
+
if (!dateStr) return '—';
|
|
341
|
+
const d = new Date(dateStr);
|
|
342
|
+
return d.toLocaleString();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function refreshAll() {
|
|
346
|
+
loadStats();
|
|
347
|
+
loadErrors();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
document.getElementById('btn-filter').addEventListener('click', () => { currentPage = 1; loadErrors(); });
|
|
351
|
+
document.getElementById('filter-search').addEventListener('keypress', (e) => { if (e.key === 'Enter') { currentPage = 1; loadErrors(); } });
|
|
352
|
+
document.getElementById('btn-prev').addEventListener('click', () => { if (currentPage > 1) { currentPage--; loadErrors(); } });
|
|
353
|
+
document.getElementById('btn-next').addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; loadErrors(); } });
|
|
354
|
+
document.getElementById('modal-close').addEventListener('click', closeModal);
|
|
355
|
+
document.getElementById('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
|
|
356
|
+
|
|
357
|
+
refreshAll();
|
|
358
|
+
</script>
|
|
359
|
+
<script>
|
|
360
|
+
window.addEventListener("keydown", (e) => {
|
|
361
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
362
|
+
e.preventDefault();
|
|
363
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
</script>
|
|
367
|
+
</body>
|
|
368
|
+
</html>
|