agentgate 0.3.2 → 0.5.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.
@@ -0,0 +1,599 @@
1
+ // Queue routes - write queue management
2
+ import { Router } from 'express';
3
+ import {
4
+ listQueueEntries, getQueueEntry, updateQueueStatus,
5
+ clearQueueByStatus, deleteQueueEntry, getQueueCounts,
6
+ getQueueWarnings
7
+ } from '../../lib/db.js';
8
+ import { executeQueueEntry } from '../../lib/queueExecutor.js';
9
+ import { notifyAgentQueueStatus } from '../../lib/agentNotifier.js';
10
+ import { emitCountUpdate, emitEvent } from '../../lib/socketManager.js';
11
+ import { escapeHtml, renderMarkdownLinks, statusBadge, formatDate, simpleNavHeader, socketScript, localizeScript, renderAvatar } from './shared.js';
12
+
13
+ const router = Router();
14
+
15
+ // Write Queue Management
16
+ router.get('/', (req, res) => {
17
+ const filter = req.query.filter || 'all';
18
+ let entries;
19
+ if (filter === 'all') {
20
+ entries = listQueueEntries();
21
+ } else {
22
+ entries = listQueueEntries(filter);
23
+ }
24
+ const counts = getQueueCounts();
25
+ res.send(renderQueuePage(entries, filter, counts));
26
+ });
27
+
28
+ router.post('/:id/approve', async (req, res) => {
29
+ const { id } = req.params;
30
+ const entry = getQueueEntry(id);
31
+ const wantsJson = req.headers.accept?.includes('application/json');
32
+
33
+ if (!entry) {
34
+ return wantsJson
35
+ ? res.status(404).json({ error: 'Queue entry not found' })
36
+ : res.status(404).send('Queue entry not found');
37
+ }
38
+
39
+ if (entry.status !== 'pending') {
40
+ return wantsJson
41
+ ? res.status(400).json({ error: 'Can only approve pending requests' })
42
+ : res.status(400).send('Can only approve pending requests');
43
+ }
44
+
45
+ updateQueueStatus(id, 'approved');
46
+
47
+ try {
48
+ await executeQueueEntry(entry);
49
+ } catch (err) {
50
+ updateQueueStatus(id, 'failed', { results: [{ error: err.message }] });
51
+ }
52
+
53
+ const updated = getQueueEntry(id);
54
+ const counts = getQueueCounts();
55
+
56
+ emitCountUpdate();
57
+ emitEvent('queueItemUpdate', {
58
+ id,
59
+ type: 'status_changed',
60
+ status: updated.status,
61
+ entry: updated
62
+ });
63
+
64
+ if (wantsJson) {
65
+ return res.json({ success: true, entry: updated, counts });
66
+ }
67
+ res.redirect('/ui/queue');
68
+ });
69
+
70
+ router.post('/:id/reject', async (req, res) => {
71
+ const { id } = req.params;
72
+ const { reason } = req.body;
73
+ const wantsJson = req.headers.accept?.includes('application/json');
74
+
75
+ const entry = getQueueEntry(id);
76
+ if (!entry) {
77
+ return wantsJson
78
+ ? res.status(404).json({ error: 'Queue entry not found' })
79
+ : res.status(404).send('Queue entry not found');
80
+ }
81
+
82
+ if (entry.status !== 'pending') {
83
+ return wantsJson
84
+ ? res.status(400).json({ error: 'Can only reject pending requests' })
85
+ : res.status(400).send('Can only reject pending requests');
86
+ }
87
+
88
+ updateQueueStatus(id, 'rejected', { rejection_reason: reason || 'No reason provided' });
89
+
90
+ const updated = getQueueEntry(id);
91
+ notifyAgentQueueStatus(updated).catch(err => {
92
+ console.error('[agentNotifier] Failed to notify agent:', err.message);
93
+ });
94
+
95
+ const counts = getQueueCounts();
96
+ emitCountUpdate();
97
+ emitEvent('queueItemUpdate', {
98
+ id,
99
+ type: 'status_changed',
100
+ status: updated.status,
101
+ entry: updated
102
+ });
103
+
104
+ if (wantsJson) {
105
+ return res.json({ success: true, entry: updated, counts });
106
+ }
107
+ res.redirect('/ui/queue');
108
+ });
109
+
110
+ router.post('/clear', (req, res) => {
111
+ const wantsJson = req.headers.accept?.includes('application/json');
112
+ const { status } = req.body;
113
+
114
+ const allowedStatuses = ['completed', 'failed', 'rejected', 'withdrawn', 'all'];
115
+ if (status && !allowedStatuses.includes(status)) {
116
+ return wantsJson
117
+ ? res.status(400).json({ error: 'Invalid status' })
118
+ : res.status(400).send('Invalid status');
119
+ }
120
+
121
+ clearQueueByStatus(status || 'all');
122
+ const counts = getQueueCounts();
123
+ emitCountUpdate();
124
+
125
+ if (wantsJson) {
126
+ return res.json({ success: true, counts });
127
+ }
128
+ res.redirect('/ui/queue');
129
+ });
130
+
131
+ router.delete('/:id', (req, res) => {
132
+ const { id } = req.params;
133
+ const wantsJson = req.headers.accept?.includes('application/json');
134
+
135
+ const entry = getQueueEntry(id);
136
+ if (!entry) {
137
+ return wantsJson
138
+ ? res.status(404).json({ error: 'Queue entry not found' })
139
+ : res.status(404).send('Queue entry not found');
140
+ }
141
+
142
+ deleteQueueEntry(id);
143
+ const counts = getQueueCounts();
144
+ emitCountUpdate();
145
+
146
+ if (wantsJson) {
147
+ return res.json({ success: true, counts });
148
+ }
149
+ res.redirect('/ui/queue');
150
+ });
151
+
152
+ router.post('/:id/notify', async (req, res) => {
153
+ const { id } = req.params;
154
+ const wantsJson = req.headers.accept?.includes('application/json');
155
+ const entry = getQueueEntry(id);
156
+
157
+ if (!entry) {
158
+ return wantsJson
159
+ ? res.status(404).json({ success: false, error: 'Entry not found' })
160
+ : res.status(404).send('Entry not found');
161
+ }
162
+
163
+ // Actually send the notification (this was missing!)
164
+ const result = await notifyAgentQueueStatus(entry);
165
+
166
+ if (wantsJson) {
167
+ return res.json({ success: result.success, error: result.error });
168
+ }
169
+ res.redirect('/ui/queue');
170
+ });
171
+
172
+ // Render function
173
+ function renderQueuePage(entries, filter, counts = {}) {
174
+ const renderEntry = (entry) => {
175
+ const requestsSummary = entry.requests.map((r) =>
176
+ `<div class="request-item"><code>${r.method}</code> <span>${escapeHtml(r.path)}</span></div>`
177
+ ).join('');
178
+
179
+ // Get warnings for this entry
180
+ const warnings = getQueueWarnings(entry.id);
181
+ const warningCount = warnings.length;
182
+ const warningBadge = warningCount > 0
183
+ ? `<span class="warning-badge" title="${warningCount} warning${warningCount > 1 ? 's' : ''}">⚠️ ${warningCount}</span>`
184
+ : '';
185
+
186
+ let warningsSection = '';
187
+ if (warningCount > 0) {
188
+ const warningItems = warnings.map(w => `
189
+ <div class="warning-item">
190
+ <div class="warning-header">
191
+ ${renderAvatar(w.agent_id, { size: 18 })}
192
+ <strong>${escapeHtml(w.agent_id)}</strong>
193
+ <span class="warning-time">${formatDate(w.created_at)}</span>
194
+ </div>
195
+ <div class="warning-message">${escapeHtml(w.message)}</div>
196
+ </div>
197
+ `).join('');
198
+
199
+ warningsSection = `
200
+ <div class="warnings-section">
201
+ <div class="warnings-header">⚠️ Warnings (${warningCount})</div>
202
+ ${warningItems}
203
+ </div>
204
+ `;
205
+ }
206
+
207
+ let actions = '';
208
+ if (entry.status === 'pending') {
209
+ actions = `
210
+ <div class="queue-actions" id="actions-${entry.id}">
211
+ <button type="button" class="btn-primary btn-sm" onclick="approveEntry('${entry.id}')">Approve</button>
212
+ <input type="text" id="reason-${entry.id}" placeholder="Rejection reason (optional)" class="reject-input">
213
+ <button type="button" class="btn-danger btn-sm" onclick="rejectEntry('${entry.id}')">Reject</button>
214
+ </div>
215
+ `;
216
+ }
217
+
218
+ let resultSection = '';
219
+ if (entry.results) {
220
+ resultSection = `
221
+ <details style="margin-top: 12px;">
222
+ <summary>Results (${entry.results.length})</summary>
223
+ <pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.results, null, 2))}</pre>
224
+ </details>
225
+ `;
226
+ }
227
+
228
+ if (entry.rejection_reason) {
229
+ resultSection = `
230
+ <div class="rejection-reason">
231
+ <strong>Rejection reason:</strong> ${escapeHtml(entry.rejection_reason)}
232
+ </div>
233
+ `;
234
+ }
235
+
236
+ let notificationSection = '';
237
+ if (['completed', 'failed', 'rejected', 'withdrawn'].includes(entry.status)) {
238
+ const notifyStatus = entry.notified
239
+ ? `<span class="notify-status notify-sent" title="Notified at ${formatDate(entry.notified_at)}">✓ Notified</span>`
240
+ : entry.notify_error
241
+ ? `<span class="notify-status notify-failed" title="${escapeHtml(entry.notify_error)}">⚠ Notify failed</span>`
242
+ : '<span class="notify-status notify-pending">— Not notified</span>';
243
+
244
+ const retryBtn = !entry.notified
245
+ ? `<button type="button" class="btn-sm btn-link" onclick="retryNotify('${entry.id}')" id="retry-${entry.id}">Retry</button>`
246
+ : '';
247
+
248
+ notificationSection = `
249
+ <div class="notification-status" id="notify-status-${entry.id}">
250
+ ${notifyStatus} ${retryBtn}
251
+ </div>
252
+ `;
253
+ }
254
+
255
+ return `
256
+ <div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}" data-notified="${entry.notified ? '1' : '0'}">
257
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
258
+ <div class="entry-header">
259
+ <strong>${entry.service}</strong> / ${entry.account_name}
260
+ <span class="status-badge">${statusBadge(entry.status)}</span>
261
+ ${warningBadge}
262
+ </div>
263
+ <div style="display: flex; align-items: center; gap: 12px;">
264
+ <span class="help" style="margin: 0;">${formatDate(entry.submitted_at)}</span>
265
+ <button type="button" class="delete-btn" onclick="deleteEntry('${entry.id}')" title="Delete">&times;</button>
266
+ </div>
267
+ </div>
268
+
269
+ ${entry.comment ? `<p class="agent-comment"><strong>Agent says:</strong> ${renderMarkdownLinks(entry.comment)}</p>` : ''}
270
+
271
+ <div class="help" style="margin-bottom: 8px;">Submitted by: <span class="agent-with-avatar">${renderAvatar(entry.submitted_by, { size: 20 })}<code>${escapeHtml(entry.submitted_by || 'unknown')}</code></span></div>
272
+
273
+ <div class="requests-list">
274
+ ${requestsSummary}
275
+ </div>
276
+
277
+ <details style="margin-top: 12px;">
278
+ <summary>Request Details</summary>
279
+ <pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.requests, null, 2))}</pre>
280
+ </details>
281
+
282
+ ${resultSection}
283
+ ${warningsSection}
284
+ ${notificationSection}
285
+ ${actions}
286
+ </div>
287
+ `;
288
+ };
289
+
290
+ const filters = ['all', 'pending', 'completed', 'failed', 'rejected', 'withdrawn'];
291
+ const filterLinks = filters.map(f =>
292
+ `<a href="/ui/queue?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
293
+ ).join('');
294
+
295
+ return `<!DOCTYPE html>
296
+ <html>
297
+ <head>
298
+ <title>agentgate - Write Queue</title>
299
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
300
+ <link rel="stylesheet" href="/public/style.css">
301
+ <script src="/socket.io/socket.io.js"></script>
302
+ <style>
303
+ .filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
304
+ .filter-link { padding: 10px 20px; border-radius: 25px; text-decoration: none; background: rgba(255, 255, 255, 0.05); color: var(--gray-400); font-weight: 600; font-size: 13px; border: 1px solid rgba(255, 255, 255, 0.1); transition: all 0.3s ease; }
305
+ .filter-link:hover { background: rgba(255, 255, 255, 0.1); color: var(--gray-200); border-color: rgba(255, 255, 255, 0.2); }
306
+ .filter-link.active { background: linear-gradient(135deg, var(--primary) 0%, #8b5cf6 100%); color: white; border-color: transparent; box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4); }
307
+ .queue-entry { margin-bottom: 20px; }
308
+ .request-item { padding: 12px 16px; background: rgba(0, 0, 0, 0.2); border-radius: 8px; margin: 6px 0; font-size: 14px; border: 1px solid rgba(255, 255, 255, 0.05); display: flex; align-items: center; gap: 12px; }
309
+ .request-item code { background: rgba(99, 102, 241, 0.2); padding: 4px 10px; border-radius: 6px; font-weight: 700; color: var(--primary-light); border: 1px solid rgba(99, 102, 241, 0.3); font-size: 12px; }
310
+ .request-item span { color: var(--gray-300); }
311
+ .queue-actions { margin-top: 20px; padding-top: 20px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
312
+ .back-link { color: #818cf8; text-decoration: none; font-weight: 600; transition: color 0.2s ease; }
313
+ .back-link:hover { color: #ffffff; }
314
+ .delete-btn { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); color: #f87171; font-size: 18px; cursor: pointer; padding: 4px 10px; line-height: 1; font-weight: bold; border-radius: 6px; transition: all 0.2s ease; }
315
+ .delete-btn:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); }
316
+ .clear-section { margin-left: auto; display: flex; gap: 10px; }
317
+ .entry-header { display: flex; align-items: center; gap: 12px; }
318
+ .entry-header strong { color: #f3f4f6; font-size: 16px; }
319
+ .reject-input { width: 240px; padding: 10px 14px; margin: 0; font-size: 13px; background: rgba(15, 15, 25, 0.6); border: 2px solid rgba(239, 68, 68, 0.2); border-radius: 8px; color: #f3f4f6; }
320
+ .reject-input:focus { outline: none; border-color: #f87171; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); }
321
+ .reject-input::placeholder { color: #6b7280; }
322
+ .agent-comment { margin: 0 0 16px 0; padding: 16px; background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); border-radius: 10px; border-left: 4px solid #6366f1; color: #e5e7eb; }
323
+ .agent-comment strong { color: #818cf8; }
324
+ .agent-comment a { color: #818cf8; }
325
+ .rejection-reason { margin-top: 16px; padding: 16px; background: rgba(239, 68, 68, 0.1); border-radius: 10px; border-left: 4px solid #f87171; color: #e5e7eb; }
326
+ .rejection-reason strong { color: #f87171; }
327
+ .empty-state { text-align: center; padding: 60px 40px; }
328
+ .empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
329
+ .notification-status { margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 12px; font-size: 13px; }
330
+ .notify-status { padding: 4px 10px; border-radius: 6px; font-weight: 500; }
331
+ .notify-sent { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
332
+ .notify-failed { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
333
+ .notify-pending { background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3); }
334
+ .btn-link { background: none; border: none; color: #818cf8; cursor: pointer; text-decoration: underline; padding: 4px 8px; font-size: 13px; }
335
+ .btn-link:hover { color: #a5b4fc; }
336
+ .warning-badge { background: rgba(245, 158, 11, 0.2); color: #fbbf24; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; border: 1px solid rgba(245, 158, 11, 0.3); }
337
+ .warnings-section { margin-top: 16px; padding: 16px; background: rgba(245, 158, 11, 0.08); border-radius: 10px; border: 1px solid rgba(245, 158, 11, 0.2); }
338
+ .warnings-header { color: #fbbf24; font-weight: 600; margin-bottom: 12px; font-size: 14px; }
339
+ .warning-item { padding: 12px; background: rgba(0, 0, 0, 0.2); border-radius: 8px; margin-bottom: 8px; border-left: 3px solid #f59e0b; }
340
+ .warning-item:last-child { margin-bottom: 0; }
341
+ .warning-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 13px; }
342
+ .warning-header strong { color: #fbbf24; }
343
+ .warning-time { color: #6b7280; font-size: 12px; margin-left: auto; }
344
+ .warning-message { color: #e5e7eb; font-size: 14px; line-height: 1.5; }
345
+ </style>
346
+ </head>
347
+ <body>
348
+ ${simpleNavHeader()}
349
+ <h2 style="margin-top: 0;">Write Queue</h2>
350
+ <p>Review and approve write requests from agents.</p>
351
+
352
+ <div class="filter-bar" id="filter-bar">
353
+ ${filterLinks}
354
+ <div class="clear-section">
355
+ ${filter === 'completed' && counts.completed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'completed\')">Clear Completed</button>' : ''}
356
+ ${filter === 'failed' && counts.failed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'failed\')">Clear Failed</button>' : ''}
357
+ ${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
358
+ ${filter === 'all' && (counts.completed > 0 || counts.failed > 0 || counts.rejected > 0) ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'all\')">Clear All Non-Pending</button>' : ''}
359
+ <a href="/ui/queue/export?format=json" class="btn-sm" style="text-decoration: none;">Export JSON</a>
360
+ <a href="/ui/queue/export?format=csv" class="btn-sm" style="text-decoration: none;">Export CSV</a>
361
+ </div>
362
+ </div>
363
+
364
+ <div id="entries-container">
365
+ ${entries.length === 0 ? `
366
+ <div class="card empty-state">
367
+ <p>No ${filter === 'all' ? '' : filter + ' '}requests in queue</p>
368
+ </div>
369
+ ` : entries.map(renderEntry).join('')}
370
+ </div>
371
+
372
+ <script>
373
+ function escapeHtml(str) {
374
+ if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
375
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
376
+ }
377
+
378
+ async function approveEntry(id) {
379
+ const btn = event.target;
380
+ btn.disabled = true;
381
+ btn.textContent = 'Approving...';
382
+ try {
383
+ const res = await fetch('/ui/queue/' + id + '/approve', { method: 'POST', headers: { 'Accept': 'application/json' } });
384
+ const data = await res.json();
385
+ if (data.success) {
386
+ window.location.reload();
387
+ } else {
388
+ alert(data.error || 'Failed to approve');
389
+ btn.disabled = false;
390
+ btn.textContent = 'Approve';
391
+ }
392
+ } catch (err) {
393
+ alert('Error: ' + err.message);
394
+ btn.disabled = false;
395
+ btn.textContent = 'Approve';
396
+ }
397
+ }
398
+
399
+ async function rejectEntry(id) {
400
+ const btn = event.target;
401
+ const reasonInput = document.getElementById('reason-' + id);
402
+ const reason = reasonInput ? reasonInput.value : '';
403
+ btn.disabled = true;
404
+ btn.textContent = 'Rejecting...';
405
+ try {
406
+ const res = await fetch('/ui/queue/' + id + '/reject', {
407
+ method: 'POST',
408
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
409
+ body: JSON.stringify({ reason })
410
+ });
411
+ const data = await res.json();
412
+ if (data.success) {
413
+ window.location.reload();
414
+ } else {
415
+ alert(data.error || 'Failed to reject');
416
+ btn.disabled = false;
417
+ btn.textContent = 'Reject';
418
+ }
419
+ } catch (err) {
420
+ alert('Error: ' + err.message);
421
+ btn.disabled = false;
422
+ btn.textContent = 'Reject';
423
+ }
424
+ }
425
+
426
+ async function clearByStatus(status) {
427
+ const btn = event.target;
428
+ btn.disabled = true;
429
+ btn.textContent = 'Clearing...';
430
+ try {
431
+ const res = await fetch('/ui/queue/clear', {
432
+ method: 'POST',
433
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({ status })
435
+ });
436
+ const data = await res.json();
437
+ if (data.success) {
438
+ window.location.reload();
439
+ }
440
+ } catch (err) {
441
+ alert('Error: ' + err.message);
442
+ btn.disabled = false;
443
+ }
444
+ }
445
+
446
+ async function deleteEntry(id) {
447
+ if (!confirm('Delete this queue entry?')) return;
448
+ try {
449
+ const res = await fetch('/ui/queue/' + id, { method: 'DELETE', headers: { 'Accept': 'application/json' } });
450
+ const data = await res.json();
451
+ if (data.success) {
452
+ document.getElementById('entry-' + id)?.remove();
453
+ const container = document.getElementById('entries-container');
454
+ if (container.querySelectorAll('.queue-entry').length === 0) {
455
+ container.innerHTML = '<div class="card empty-state"><p>No requests in queue</p></div>';
456
+ }
457
+ } else {
458
+ alert(data.error || 'Failed to delete');
459
+ }
460
+ } catch (err) {
461
+ alert('Error: ' + err.message);
462
+ }
463
+ }
464
+
465
+ async function retryNotify(id) {
466
+ const btn = document.getElementById('retry-' + id);
467
+ const statusContainer = document.getElementById('notify-status-' + id);
468
+ if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
469
+ try {
470
+ const res = await fetch('/ui/queue/' + id + '/notify', { method: 'POST', headers: { 'Accept': 'application/json' } });
471
+ const data = await res.json();
472
+ if (data.success) {
473
+ // Update inline instead of page refresh
474
+ if (statusContainer) {
475
+ statusContainer.innerHTML = '<span class="notify-status notify-sent">✓ Notified</span>';
476
+ }
477
+ } else {
478
+ // Show error and re-enable retry button
479
+ if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
480
+ const errorMsg = data.error || 'Unknown error';
481
+ alert('Notification failed: ' + errorMsg);
482
+ }
483
+ } catch (err) {
484
+ alert('Error: ' + err.message);
485
+ if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
486
+ }
487
+ }
488
+
489
+ // Real-time queue item updates via socket.io
490
+ document.addEventListener('DOMContentLoaded', function() {
491
+ const socket = io();
492
+
493
+ socket.on('queueItemUpdate', function(data) {
494
+ const entryEl = document.getElementById('entry-' + data.id);
495
+ if (!entryEl) return;
496
+
497
+ if (data.type === 'warning_added') {
498
+ // Update warning badge
499
+ const headerEl = entryEl.querySelector('.entry-header');
500
+ if (headerEl) {
501
+ let badgeEl = headerEl.querySelector('.warning-badge');
502
+ if (badgeEl) {
503
+ badgeEl.textContent = '⚠️ ' + data.warningCount;
504
+ badgeEl.title = data.warningCount + ' warning' + (data.warningCount > 1 ? 's' : '');
505
+ } else {
506
+ const badge = document.createElement('span');
507
+ badge.className = 'warning-badge';
508
+ badge.textContent = '⚠️ ' + data.warningCount;
509
+ badge.title = data.warningCount + ' warning' + (data.warningCount > 1 ? 's' : '');
510
+ headerEl.appendChild(badge);
511
+ }
512
+ }
513
+
514
+ // Update or add warnings section
515
+ let warningsSection = entryEl.querySelector('.warnings-section');
516
+ if (!warningsSection) {
517
+ warningsSection = document.createElement('div');
518
+ warningsSection.className = 'warnings-section';
519
+ // Insert after the entry header area
520
+ const actionsEl = entryEl.querySelector('.queue-actions');
521
+ if (actionsEl) {
522
+ actionsEl.parentNode.insertBefore(warningsSection, actionsEl);
523
+ } else {
524
+ entryEl.appendChild(warningsSection);
525
+ }
526
+ }
527
+
528
+ // Rebuild warnings content
529
+ const warningItems = data.warnings.map(w =>
530
+ '<div class="warning-item">' +
531
+ '<div class="warning-header">' +
532
+ '<strong>' + escapeHtml(w.agent_id) + '</strong>' +
533
+ '<span class="warning-time">' + new Date(w.created_at).toLocaleString() + '</span>' +
534
+ '</div>' +
535
+ '<div class="warning-message">' + escapeHtml(w.message) + '</div>' +
536
+ '</div>'
537
+ ).join('');
538
+
539
+ warningsSection.innerHTML =
540
+ '<div class="warnings-header">⚠️ Warnings (' + data.warningCount + ')</div>' +
541
+ warningItems;
542
+ }
543
+
544
+ if (data.type === 'status_changed') {
545
+ // Update status badge
546
+ const statusBadge = entryEl.querySelector('.status-badge .status');
547
+ if (statusBadge) {
548
+ statusBadge.textContent = data.status;
549
+ statusBadge.className = 'status ' + data.status;
550
+ }
551
+ entryEl.dataset.status = data.status;
552
+
553
+ // Remove action buttons if no longer pending
554
+ if (data.status !== 'pending') {
555
+ const actionsEl = entryEl.querySelector('.queue-actions');
556
+ if (actionsEl) actionsEl.remove();
557
+ }
558
+ }
559
+ });
560
+ });
561
+ </script>
562
+ ${socketScript()}
563
+ ${localizeScript()}
564
+ </body>
565
+ </html>`;
566
+ }
567
+
568
+ // Export queue data
569
+ router.get('/export', (req, res) => {
570
+ try {
571
+ const format = req.query.format || 'json';
572
+ const entries = listQueueEntries();
573
+
574
+ if (format === 'csv') {
575
+ const headers = ['id', 'service', 'account_name', 'status', 'comment', 'submitted_by', 'rejection_reason', 'submitted_at', 'reviewed_at', 'completed_at'];
576
+ const csvRows = [headers.join(',')];
577
+ for (const entry of entries) {
578
+ const row = headers.map(h => {
579
+ const val = entry[h] ?? '';
580
+ const str = String(val).replace(/"/g, '""');
581
+ return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
582
+ });
583
+ csvRows.push(row.join(','));
584
+ }
585
+ res.setHeader('Content-Type', 'text/csv');
586
+ res.setHeader('Content-Disposition', 'attachment; filename="queue-export.csv"');
587
+ return res.send(csvRows.join('\n'));
588
+ }
589
+
590
+ res.setHeader('Content-Type', 'application/json');
591
+ res.setHeader('Content-Disposition', 'attachment; filename="queue-export.json"');
592
+ res.json(entries);
593
+ } catch (err) {
594
+ console.error('Queue export error:', err);
595
+ res.status(500).json({ error: 'Export failed', message: err.message });
596
+ }
597
+ });
598
+
599
+ export default router;