carom-link 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -0
- package/bin/carom.js +2 -0
- package/package.json +46 -0
- package/public/app.js +519 -0
- package/public/index.html +233 -0
- package/public/style.css +756 -0
- package/src/cli/commands/add.js +106 -0
- package/src/cli/commands/config.js +95 -0
- package/src/cli/commands/install.js +50 -0
- package/src/cli/commands/list.js +62 -0
- package/src/cli/commands/logs.js +70 -0
- package/src/cli/commands/remove.js +36 -0
- package/src/cli/commands/rules.js +168 -0
- package/src/cli/commands/start.js +43 -0
- package/src/cli/commands/stats.js +86 -0
- package/src/cli/commands/status.js +89 -0
- package/src/cli/commands/uninstall.js +28 -0
- package/src/cli/formatters.js +132 -0
- package/src/cli/index.js +45 -0
- package/src/cloak/detector.js +243 -0
- package/src/cloak/ipLookup.js +146 -0
- package/src/cloak/patterns.js +160 -0
- package/src/cloak/safePage.js +146 -0
- package/src/cloak/tokens.js +67 -0
- package/src/config.js +152 -0
- package/src/constants.js +78 -0
- package/src/db.js +256 -0
- package/src/server/app.js +110 -0
- package/src/server/routes/api.js +268 -0
- package/src/server/routes/redirect.js +141 -0
- package/src/server/server.js +117 -0
- package/src/service/launchd.js +166 -0
- package/src/service/manager.js +79 -0
- package/src/service/systemd.js +147 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* carom Dashboard — Client-side JavaScript
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const API_BASE = window.location.origin + '/api';
|
|
6
|
+
|
|
7
|
+
// ── State ──
|
|
8
|
+
let apiKey = localStorage.getItem('carom-api-key') || '';
|
|
9
|
+
|
|
10
|
+
// ── DOM Elements ──
|
|
11
|
+
const apiKeyInput = document.getElementById('apiKeyInput');
|
|
12
|
+
const apiKeyToggle = document.getElementById('apiKeyToggle');
|
|
13
|
+
const statusBadge = document.getElementById('statusBadge');
|
|
14
|
+
const statusText = statusBadge.querySelector('.status-text');
|
|
15
|
+
|
|
16
|
+
// ── Init ──
|
|
17
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
18
|
+
apiKeyInput.value = apiKey;
|
|
19
|
+
setupTabs();
|
|
20
|
+
setupApiKey();
|
|
21
|
+
setupCreateForm();
|
|
22
|
+
setupRulesForm();
|
|
23
|
+
setupTestForm();
|
|
24
|
+
setupBotPageForm();
|
|
25
|
+
setupStatsModal();
|
|
26
|
+
loadLinks();
|
|
27
|
+
checkConnection();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── API Helper ──
|
|
31
|
+
async function api(method, path, body) {
|
|
32
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
33
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
34
|
+
|
|
35
|
+
const opts = { method, headers };
|
|
36
|
+
if (body) opts.body = JSON.stringify(body);
|
|
37
|
+
|
|
38
|
+
const res = await fetch(API_BASE + path, opts);
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Connection Check ──
|
|
49
|
+
async function checkConnection() {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(window.location.origin + '/health');
|
|
52
|
+
statusBadge.className = 'status-badge connected';
|
|
53
|
+
statusText.textContent = 'Connected';
|
|
54
|
+
} catch {
|
|
55
|
+
statusBadge.className = 'status-badge error';
|
|
56
|
+
statusText.textContent = 'Disconnected';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Tabs ──
|
|
61
|
+
function setupTabs() {
|
|
62
|
+
const navBtns = document.querySelectorAll('.nav-btn');
|
|
63
|
+
navBtns.forEach(btn => {
|
|
64
|
+
btn.addEventListener('click', () => {
|
|
65
|
+
navBtns.forEach(b => b.classList.remove('active'));
|
|
66
|
+
btn.classList.add('active');
|
|
67
|
+
|
|
68
|
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
69
|
+
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
70
|
+
|
|
71
|
+
// Load data when switching tabs
|
|
72
|
+
if (btn.dataset.tab === 'links') loadLinks();
|
|
73
|
+
if (btn.dataset.tab === 'rules') loadRules();
|
|
74
|
+
if (btn.dataset.tab === 'botpage') loadBotPageSettings();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── API Key ──
|
|
80
|
+
function setupApiKey() {
|
|
81
|
+
apiKeyInput.addEventListener('change', () => {
|
|
82
|
+
apiKey = apiKeyInput.value;
|
|
83
|
+
localStorage.setItem('carom-api-key', apiKey);
|
|
84
|
+
toast('API key saved', 'success');
|
|
85
|
+
loadLinks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
apiKeyToggle.addEventListener('click', () => {
|
|
89
|
+
apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password';
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Links ──
|
|
94
|
+
async function loadLinks() {
|
|
95
|
+
const tbody = document.getElementById('linksBody');
|
|
96
|
+
try {
|
|
97
|
+
const data = await api('GET', '/links?all=true');
|
|
98
|
+
if (!data.links || data.links.length === 0) {
|
|
99
|
+
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No links yet. Create one!</td></tr>';
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
tbody.innerHTML = data.links.map(link => `
|
|
104
|
+
<tr>
|
|
105
|
+
<td class="cell-slug">${esc(link.slug)}</td>
|
|
106
|
+
<td class="cell-url" title="${esc(link.destination_url)}">${esc(link.destination_url)}</td>
|
|
107
|
+
<td class="cell-count human">${link.human_clicks || 0}</td>
|
|
108
|
+
<td class="cell-count bot">${link.bot_clicks || 0}</td>
|
|
109
|
+
<td>${formatDate(link.created_at)}</td>
|
|
110
|
+
<td>${link.active ? '<span class="badge badge-active">Active</span>' : '<span class="badge badge-inactive">Inactive</span>'}</td>
|
|
111
|
+
<td>
|
|
112
|
+
<button class="btn-secondary btn-sm" onclick="viewStats('${esc(link.slug)}')">📊 Stats</button>
|
|
113
|
+
<button class="btn-ghost btn-sm" onclick="copyLink('${esc(link.shortUrl)}')">📋</button>
|
|
114
|
+
${link.active ? `<button class="btn-danger btn-sm" onclick="deleteLink('${link.id}')">✕</button>` : ''}
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
`).join('');
|
|
118
|
+
} catch (err) {
|
|
119
|
+
tbody.innerHTML = `<tr><td colspan="7" class="empty-state">Error: ${esc(err.message)}</td></tr>`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
document.getElementById('refreshLinks').addEventListener('click', loadLinks);
|
|
124
|
+
|
|
125
|
+
// ── Create Link ──
|
|
126
|
+
function setupCreateForm() {
|
|
127
|
+
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const btn = document.getElementById('createBtn');
|
|
130
|
+
btn.disabled = true;
|
|
131
|
+
btn.textContent = 'Creating...';
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const body = {
|
|
135
|
+
url: document.getElementById('urlInput').value,
|
|
136
|
+
};
|
|
137
|
+
const slug = document.getElementById('slugInput').value;
|
|
138
|
+
if (slug) body.slug = slug;
|
|
139
|
+
const expires = document.getElementById('expiresInput').value;
|
|
140
|
+
if (expires) body.expiresAt = new Date(expires).toISOString();
|
|
141
|
+
|
|
142
|
+
const data = await api('POST', '/links', body);
|
|
143
|
+
|
|
144
|
+
const resultDiv = document.getElementById('createResult');
|
|
145
|
+
const resultUrl = document.getElementById('resultUrl');
|
|
146
|
+
resultUrl.textContent = data.shortUrl;
|
|
147
|
+
resultDiv.classList.remove('hidden');
|
|
148
|
+
|
|
149
|
+
document.getElementById('urlInput').value = '';
|
|
150
|
+
document.getElementById('slugInput').value = '';
|
|
151
|
+
document.getElementById('expiresInput').value = '';
|
|
152
|
+
|
|
153
|
+
toast('Link created!', 'success');
|
|
154
|
+
} catch (err) {
|
|
155
|
+
toast(err.message, 'error');
|
|
156
|
+
} finally {
|
|
157
|
+
btn.disabled = false;
|
|
158
|
+
btn.textContent = 'Create Short Link';
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
document.getElementById('copyBtn').addEventListener('click', () => {
|
|
163
|
+
const url = document.getElementById('resultUrl').textContent;
|
|
164
|
+
copyToClipboard(url);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Rules ──
|
|
169
|
+
async function loadRules() {
|
|
170
|
+
const tbody = document.getElementById('rulesBody');
|
|
171
|
+
try {
|
|
172
|
+
const data = await api('GET', '/rules');
|
|
173
|
+
if (!data.rules || data.rules.length === 0) {
|
|
174
|
+
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No custom rules. Add one above.</td></tr>';
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
tbody.innerHTML = data.rules.map(rule => `
|
|
179
|
+
<tr>
|
|
180
|
+
<td>${rule.id}</td>
|
|
181
|
+
<td><span class="badge badge-active">${esc(rule.type)}</span></td>
|
|
182
|
+
<td class="cell-slug">${esc(rule.pattern)}</td>
|
|
183
|
+
<td class="cell-count">+${rule.weight}</td>
|
|
184
|
+
<td>${formatDate(rule.created_at)}</td>
|
|
185
|
+
<td>
|
|
186
|
+
<button class="btn-danger btn-sm" onclick="deleteRule(${rule.id})">✕ Remove</button>
|
|
187
|
+
</td>
|
|
188
|
+
</tr>
|
|
189
|
+
`).join('');
|
|
190
|
+
} catch (err) {
|
|
191
|
+
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">Error: ${esc(err.message)}</td></tr>`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
document.getElementById('refreshRules').addEventListener('click', loadRules);
|
|
196
|
+
|
|
197
|
+
function setupRulesForm() {
|
|
198
|
+
document.getElementById('addRuleForm').addEventListener('submit', async (e) => {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
try {
|
|
201
|
+
await api('POST', '/rules', {
|
|
202
|
+
type: document.getElementById('ruleType').value,
|
|
203
|
+
pattern: document.getElementById('rulePattern').value,
|
|
204
|
+
weight: parseInt(document.getElementById('ruleWeight').value, 10) || 30,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
document.getElementById('rulePattern').value = '';
|
|
208
|
+
loadRules();
|
|
209
|
+
toast('Rule added', 'success');
|
|
210
|
+
} catch (err) {
|
|
211
|
+
toast(err.message, 'error');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── UA Tester ──
|
|
217
|
+
function setupTestForm() {
|
|
218
|
+
document.getElementById('testForm').addEventListener('submit', async (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
try {
|
|
221
|
+
const userAgent = document.getElementById('testUa').value;
|
|
222
|
+
const data = await api('POST', '/rules/test', { userAgent });
|
|
223
|
+
|
|
224
|
+
const resultDiv = document.getElementById('testResult');
|
|
225
|
+
resultDiv.classList.remove('hidden');
|
|
226
|
+
|
|
227
|
+
const classEl = document.getElementById('testClassification');
|
|
228
|
+
classEl.textContent = data.classification === 'bot' ? '🛡 BOT DETECTED' :
|
|
229
|
+
data.classification === 'suspicious' ? '⚠ SUSPICIOUS' : '👤 HUMAN';
|
|
230
|
+
classEl.className = 'test-classification ' + data.classification;
|
|
231
|
+
|
|
232
|
+
document.getElementById('testScore').textContent = `Score: ${data.score} / 40`;
|
|
233
|
+
|
|
234
|
+
const signalsEl = document.getElementById('testSignals');
|
|
235
|
+
signalsEl.innerHTML = data.signals.map(s => `
|
|
236
|
+
<div class="signal-item">
|
|
237
|
+
<span class="signal-weight">${s.weight > 0 ? '+' + s.weight : ''}</span>
|
|
238
|
+
<span class="signal-name">${esc(s.name)}</span>
|
|
239
|
+
<span class="signal-detail">${esc(s.detail)}</span>
|
|
240
|
+
</div>
|
|
241
|
+
`).join('');
|
|
242
|
+
} catch (err) {
|
|
243
|
+
toast(err.message, 'error');
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Bot Page Editor ──
|
|
249
|
+
async function loadBotPageSettings() {
|
|
250
|
+
try {
|
|
251
|
+
const data = await api('GET', '/config/safe-page');
|
|
252
|
+
document.getElementById('bpBrand').value = data.brand || '';
|
|
253
|
+
document.getElementById('bpTitle').value = data.title || '';
|
|
254
|
+
document.getElementById('bpDescription').value = data.description || '';
|
|
255
|
+
renderBotPagePreview(data.brand, data.title, data.description);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
toast('Failed to load bot page settings: ' + err.message, 'error');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderBotPagePreview(brand, title, description) {
|
|
262
|
+
const safeBrand = esc(brand || 'Website');
|
|
263
|
+
const safeTitle = esc(title || 'Welcome');
|
|
264
|
+
const safeDesc = esc(description || 'Visit our website for more information.');
|
|
265
|
+
const year = new Date().getFullYear();
|
|
266
|
+
|
|
267
|
+
const html = `<!DOCTYPE html>
|
|
268
|
+
<html lang="en">
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="UTF-8">
|
|
271
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
272
|
+
<title>${safeTitle} | ${safeBrand}</title>
|
|
273
|
+
<style>
|
|
274
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
275
|
+
body {
|
|
276
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
277
|
+
background: #f8f9fa;
|
|
278
|
+
color: #333;
|
|
279
|
+
min-height: 100vh;
|
|
280
|
+
display: flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
justify-content: center;
|
|
283
|
+
}
|
|
284
|
+
.container { text-align: center; padding: 2rem; max-width: 480px; }
|
|
285
|
+
.brand { font-size: 1.5rem; font-weight: 700; color: #2c3e50; margin-bottom: 1rem; }
|
|
286
|
+
.message { font-size: 1rem; color: #666; line-height: 1.6; }
|
|
287
|
+
.footer { margin-top: 2rem; font-size: 0.8rem; color: #999; }
|
|
288
|
+
</style>
|
|
289
|
+
</head>
|
|
290
|
+
<body>
|
|
291
|
+
<div class="container">
|
|
292
|
+
<div class="brand">${safeBrand}</div>
|
|
293
|
+
<p class="message">${safeDesc}</p>
|
|
294
|
+
<div class="footer">© ${year} ${safeBrand}</div>
|
|
295
|
+
</div>
|
|
296
|
+
</body>
|
|
297
|
+
</html>`;
|
|
298
|
+
|
|
299
|
+
const iframe = document.getElementById('botPagePreview');
|
|
300
|
+
iframe.srcdoc = html;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function setupBotPageForm() {
|
|
304
|
+
// Live preview on input
|
|
305
|
+
const brandEl = document.getElementById('bpBrand');
|
|
306
|
+
const titleEl = document.getElementById('bpTitle');
|
|
307
|
+
const descEl = document.getElementById('bpDescription');
|
|
308
|
+
|
|
309
|
+
const updatePreview = () => {
|
|
310
|
+
renderBotPagePreview(brandEl.value, titleEl.value, descEl.value);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
brandEl.addEventListener('input', updatePreview);
|
|
314
|
+
titleEl.addEventListener('input', updatePreview);
|
|
315
|
+
descEl.addEventListener('input', updatePreview);
|
|
316
|
+
|
|
317
|
+
// Refresh preview button
|
|
318
|
+
document.getElementById('refreshPreview').addEventListener('click', updatePreview);
|
|
319
|
+
|
|
320
|
+
// Save form
|
|
321
|
+
document.getElementById('botPageForm').addEventListener('submit', async (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
const btn = document.getElementById('saveBotPage');
|
|
324
|
+
btn.disabled = true;
|
|
325
|
+
btn.textContent = 'Saving...';
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const data = await api('PUT', '/config/safe-page', {
|
|
329
|
+
brand: brandEl.value,
|
|
330
|
+
title: titleEl.value,
|
|
331
|
+
description: descEl.value,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const statusEl = document.getElementById('botPageStatus');
|
|
335
|
+
statusEl.classList.remove('hidden');
|
|
336
|
+
statusEl.textContent = '✓ ' + (data.message || 'Settings saved. Changes are live immediately.');
|
|
337
|
+
statusEl.style.borderColor = 'var(--green)';
|
|
338
|
+
statusEl.style.background = 'var(--green-dim)';
|
|
339
|
+
statusEl.style.color = 'var(--green)';
|
|
340
|
+
|
|
341
|
+
toast('Bot page settings saved', 'success');
|
|
342
|
+
|
|
343
|
+
setTimeout(() => statusEl.classList.add('hidden'), 4000);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
toast('Failed to save: ' + err.message, 'error');
|
|
346
|
+
} finally {
|
|
347
|
+
btn.disabled = false;
|
|
348
|
+
btn.textContent = '💾 Save Changes';
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Stats Modal ──
|
|
354
|
+
function setupStatsModal() {
|
|
355
|
+
document.getElementById('closeStats').addEventListener('click', () => {
|
|
356
|
+
document.getElementById('statsModal').classList.add('hidden');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
document.getElementById('statsModal').addEventListener('click', (e) => {
|
|
360
|
+
if (e.target === e.currentTarget) {
|
|
361
|
+
e.currentTarget.classList.add('hidden');
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
window.viewStats = async function(slug) {
|
|
367
|
+
const modal = document.getElementById('statsModal');
|
|
368
|
+
const title = document.getElementById('statsTitle');
|
|
369
|
+
const body = document.getElementById('statsBody');
|
|
370
|
+
|
|
371
|
+
title.textContent = `Stats: /${slug}`;
|
|
372
|
+
body.innerHTML = 'Loading...';
|
|
373
|
+
modal.classList.remove('hidden');
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const data = await api('GET', `/links/${slug}/stats`);
|
|
377
|
+
const s = data.stats;
|
|
378
|
+
|
|
379
|
+
body.innerHTML = `
|
|
380
|
+
<div class="stat-grid">
|
|
381
|
+
<div class="stat-card">
|
|
382
|
+
<div class="stat-value">${s.totals.total || 0}</div>
|
|
383
|
+
<div class="stat-label">Total</div>
|
|
384
|
+
</div>
|
|
385
|
+
<div class="stat-card">
|
|
386
|
+
<div class="stat-value green">${s.totals.human || 0}</div>
|
|
387
|
+
<div class="stat-label">Human</div>
|
|
388
|
+
</div>
|
|
389
|
+
<div class="stat-card">
|
|
390
|
+
<div class="stat-value red">${s.totals.bot || 0}</div>
|
|
391
|
+
<div class="stat-label">Bot</div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="stat-card">
|
|
394
|
+
<div class="stat-value yellow">${s.totals.suspicious || 0}</div>
|
|
395
|
+
<div class="stat-label">Suspicious</div>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<h3 style="margin-bottom: 0.5rem; font-size: 0.9rem;">Timeline</h3>
|
|
400
|
+
<div class="stat-grid" style="grid-template-columns: repeat(3, 1fr); margin-bottom: 1.25rem;">
|
|
401
|
+
<div class="stat-card">
|
|
402
|
+
<div class="stat-value">${s.timeline.last24h}</div>
|
|
403
|
+
<div class="stat-label">Last 24h</div>
|
|
404
|
+
</div>
|
|
405
|
+
<div class="stat-card">
|
|
406
|
+
<div class="stat-value">${s.timeline.last7d}</div>
|
|
407
|
+
<div class="stat-label">Last 7d</div>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="stat-card">
|
|
410
|
+
<div class="stat-value">${s.timeline.last30d}</div>
|
|
411
|
+
<div class="stat-label">Last 30d</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
${s.topUserAgents.length > 0 ? `
|
|
416
|
+
<h3 style="margin-bottom: 0.5rem; font-size: 0.9rem;">Top User Agents</h3>
|
|
417
|
+
<div class="table-container" style="margin-bottom: 1rem;">
|
|
418
|
+
<table class="data-table">
|
|
419
|
+
<thead><tr><th>User Agent</th><th>Type</th><th>Count</th></tr></thead>
|
|
420
|
+
<tbody>
|
|
421
|
+
${s.topUserAgents.map(ua => `
|
|
422
|
+
<tr>
|
|
423
|
+
<td class="cell-url" style="max-width: 280px;">${esc(ua.user_agent || '(empty)')}</td>
|
|
424
|
+
<td>${classificationBadge(ua.classification)}</td>
|
|
425
|
+
<td class="cell-count">${ua.count}</td>
|
|
426
|
+
</tr>
|
|
427
|
+
`).join('')}
|
|
428
|
+
</tbody>
|
|
429
|
+
</table>
|
|
430
|
+
</div>
|
|
431
|
+
` : ''}
|
|
432
|
+
`;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
body.innerHTML = `<p style="color: var(--red);">Error: ${esc(err.message)}</p>`;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// ── Global Actions ──
|
|
439
|
+
window.copyLink = function(url) {
|
|
440
|
+
copyToClipboard(url);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
window.deleteLink = async function(id) {
|
|
444
|
+
if (!confirm('Deactivate this link?')) return;
|
|
445
|
+
try {
|
|
446
|
+
await api('DELETE', `/links/${id}`);
|
|
447
|
+
toast('Link deactivated', 'success');
|
|
448
|
+
loadLinks();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
toast(err.message, 'error');
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
window.deleteRule = async function(id) {
|
|
455
|
+
if (!confirm('Remove this rule?')) return;
|
|
456
|
+
try {
|
|
457
|
+
await api('DELETE', `/rules/${id}`);
|
|
458
|
+
toast('Rule removed', 'success');
|
|
459
|
+
loadRules();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
toast(err.message, 'error');
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// ── Utilities ──
|
|
466
|
+
function esc(str) {
|
|
467
|
+
const div = document.createElement('div');
|
|
468
|
+
div.textContent = str || '';
|
|
469
|
+
return div.innerHTML;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function formatDate(dateStr) {
|
|
473
|
+
if (!dateStr) return '—';
|
|
474
|
+
try {
|
|
475
|
+
const d = new Date(dateStr + 'Z');
|
|
476
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
|
|
477
|
+
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
478
|
+
} catch {
|
|
479
|
+
return dateStr;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function classificationBadge(c) {
|
|
484
|
+
switch (c) {
|
|
485
|
+
case 'human': return '<span class="badge badge-active">👤 Human</span>';
|
|
486
|
+
case 'bot': return '<span class="badge" style="background: var(--red-dim); color: var(--red);">🛡 Bot</span>';
|
|
487
|
+
case 'suspicious': return '<span class="badge" style="background: var(--yellow-dim); color: var(--yellow);">⚠ Suspicious</span>';
|
|
488
|
+
default: return `<span class="badge badge-inactive">${esc(c)}</span>`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function copyToClipboard(text) {
|
|
493
|
+
try {
|
|
494
|
+
await navigator.clipboard.writeText(text);
|
|
495
|
+
toast('Copied!', 'success');
|
|
496
|
+
} catch {
|
|
497
|
+
// Fallback
|
|
498
|
+
const input = document.createElement('input');
|
|
499
|
+
input.value = text;
|
|
500
|
+
document.body.appendChild(input);
|
|
501
|
+
input.select();
|
|
502
|
+
document.execCommand('copy');
|
|
503
|
+
document.body.removeChild(input);
|
|
504
|
+
toast('Copied!', 'success');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function toast(message, type = 'info') {
|
|
509
|
+
const el = document.createElement('div');
|
|
510
|
+
el.className = `toast ${type}`;
|
|
511
|
+
el.textContent = message;
|
|
512
|
+
document.body.appendChild(el);
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
el.style.opacity = '0';
|
|
515
|
+
el.style.transform = 'translateY(20px)';
|
|
516
|
+
el.style.transition = 'all 300ms ease';
|
|
517
|
+
setTimeout(() => el.remove(), 300);
|
|
518
|
+
}, 2500);
|
|
519
|
+
}
|