agentgate 0.3.2 → 0.4.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,420 @@
1
+ // Queue routes - write queue management
2
+ import { Router } from 'express';
3
+ import {
4
+ listQueueEntries, getQueueEntry, updateQueueStatus,
5
+ clearQueueByStatus, deleteQueueEntry, getQueueCounts
6
+ } from '../../lib/db.js';
7
+ import { executeQueueEntry } from '../../lib/queueExecutor.js';
8
+ import { notifyAgentQueueStatus } from '../../lib/agentNotifier.js';
9
+ import { emitCountUpdate } from '../../lib/socketManager.js';
10
+ import { escapeHtml, renderMarkdownLinks, statusBadge, formatDate } from './shared.js';
11
+
12
+ const router = Router();
13
+
14
+ // Write Queue Management
15
+ router.get('/', (req, res) => {
16
+ const filter = req.query.filter || 'all';
17
+ let entries;
18
+ if (filter === 'all') {
19
+ entries = listQueueEntries();
20
+ } else {
21
+ entries = listQueueEntries(filter);
22
+ }
23
+ const counts = getQueueCounts();
24
+ res.send(renderQueuePage(entries, filter, counts));
25
+ });
26
+
27
+ router.post('/:id/approve', async (req, res) => {
28
+ const { id } = req.params;
29
+ const entry = getQueueEntry(id);
30
+ const wantsJson = req.headers.accept?.includes('application/json');
31
+
32
+ if (!entry) {
33
+ return wantsJson
34
+ ? res.status(404).json({ error: 'Queue entry not found' })
35
+ : res.status(404).send('Queue entry not found');
36
+ }
37
+
38
+ if (entry.status !== 'pending') {
39
+ return wantsJson
40
+ ? res.status(400).json({ error: 'Can only approve pending requests' })
41
+ : res.status(400).send('Can only approve pending requests');
42
+ }
43
+
44
+ updateQueueStatus(id, 'approved');
45
+
46
+ try {
47
+ await executeQueueEntry(entry);
48
+ } catch (err) {
49
+ updateQueueStatus(id, 'failed', { results: [{ error: err.message }] });
50
+ }
51
+
52
+ const updated = getQueueEntry(id);
53
+ const counts = getQueueCounts();
54
+
55
+ emitCountUpdate();
56
+
57
+ if (wantsJson) {
58
+ return res.json({ success: true, entry: updated, counts });
59
+ }
60
+ res.redirect('/ui/queue');
61
+ });
62
+
63
+ router.post('/:id/reject', async (req, res) => {
64
+ const { id } = req.params;
65
+ const { reason } = req.body;
66
+ const wantsJson = req.headers.accept?.includes('application/json');
67
+
68
+ const entry = getQueueEntry(id);
69
+ if (!entry) {
70
+ return wantsJson
71
+ ? res.status(404).json({ error: 'Queue entry not found' })
72
+ : res.status(404).send('Queue entry not found');
73
+ }
74
+
75
+ if (entry.status !== 'pending') {
76
+ return wantsJson
77
+ ? res.status(400).json({ error: 'Can only reject pending requests' })
78
+ : res.status(400).send('Can only reject pending requests');
79
+ }
80
+
81
+ updateQueueStatus(id, 'rejected', { rejection_reason: reason || 'No reason provided' });
82
+
83
+ const updated = getQueueEntry(id);
84
+ notifyAgentQueueStatus(updated).catch(err => {
85
+ console.error('[agentNotifier] Failed to notify agent:', err.message);
86
+ });
87
+
88
+ const counts = getQueueCounts();
89
+ emitCountUpdate();
90
+
91
+ if (wantsJson) {
92
+ return res.json({ success: true, entry: updated, counts });
93
+ }
94
+ res.redirect('/ui/queue');
95
+ });
96
+
97
+ router.post('/clear', (req, res) => {
98
+ const wantsJson = req.headers.accept?.includes('application/json');
99
+ const { status } = req.body;
100
+
101
+ const allowedStatuses = ['completed', 'failed', 'rejected', 'all'];
102
+ if (status && !allowedStatuses.includes(status)) {
103
+ return wantsJson
104
+ ? res.status(400).json({ error: 'Invalid status' })
105
+ : res.status(400).send('Invalid status');
106
+ }
107
+
108
+ clearQueueByStatus(status || 'all');
109
+ const counts = getQueueCounts();
110
+ emitCountUpdate();
111
+
112
+ if (wantsJson) {
113
+ return res.json({ success: true, counts });
114
+ }
115
+ res.redirect('/ui/queue');
116
+ });
117
+
118
+ router.delete('/:id', (req, res) => {
119
+ const { id } = req.params;
120
+ const wantsJson = req.headers.accept?.includes('application/json');
121
+
122
+ const entry = getQueueEntry(id);
123
+ if (!entry) {
124
+ return wantsJson
125
+ ? res.status(404).json({ error: 'Queue entry not found' })
126
+ : res.status(404).send('Queue entry not found');
127
+ }
128
+
129
+ deleteQueueEntry(id);
130
+ const counts = getQueueCounts();
131
+ emitCountUpdate();
132
+
133
+ if (wantsJson) {
134
+ return res.json({ success: true, counts });
135
+ }
136
+ res.redirect('/ui/queue');
137
+ });
138
+
139
+ router.post('/:id/notify', async (req, res) => {
140
+ const { id } = req.params;
141
+ const wantsJson = req.headers.accept?.includes('application/json');
142
+ const updated = getQueueEntry(id);
143
+
144
+ if (wantsJson) {
145
+ return res.json({ success: true, entry: updated });
146
+ }
147
+ res.redirect('/ui/queue');
148
+ });
149
+
150
+ // Render function
151
+ function renderQueuePage(entries, filter, counts = {}) {
152
+ const renderEntry = (entry) => {
153
+ const requestsSummary = entry.requests.map((r) =>
154
+ `<div class="request-item"><code>${r.method}</code> <span>${escapeHtml(r.path)}</span></div>`
155
+ ).join('');
156
+
157
+ let actions = '';
158
+ if (entry.status === 'pending') {
159
+ actions = `
160
+ <div class="queue-actions" id="actions-${entry.id}">
161
+ <button type="button" class="btn-primary btn-sm" onclick="approveEntry('${entry.id}')">Approve</button>
162
+ <input type="text" id="reason-${entry.id}" placeholder="Rejection reason (optional)" class="reject-input">
163
+ <button type="button" class="btn-danger btn-sm" onclick="rejectEntry('${entry.id}')">Reject</button>
164
+ </div>
165
+ `;
166
+ }
167
+
168
+ let resultSection = '';
169
+ if (entry.results) {
170
+ resultSection = `
171
+ <details style="margin-top: 12px;">
172
+ <summary>Results (${entry.results.length})</summary>
173
+ <pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.results, null, 2))}</pre>
174
+ </details>
175
+ `;
176
+ }
177
+
178
+ if (entry.rejection_reason) {
179
+ resultSection = `
180
+ <div class="rejection-reason">
181
+ <strong>Rejection reason:</strong> ${escapeHtml(entry.rejection_reason)}
182
+ </div>
183
+ `;
184
+ }
185
+
186
+ let notificationSection = '';
187
+ if (['completed', 'failed', 'rejected'].includes(entry.status)) {
188
+ const notifyStatus = entry.notified
189
+ ? `<span class="notify-status notify-sent" title="Notified at ${formatDate(entry.notified_at)}">✓ Notified</span>`
190
+ : entry.notify_error
191
+ ? `<span class="notify-status notify-failed" title="${escapeHtml(entry.notify_error)}">⚠ Notify failed</span>`
192
+ : '<span class="notify-status notify-pending">— Not notified</span>';
193
+
194
+ const retryBtn = !entry.notified
195
+ ? `<button type="button" class="btn-sm btn-link" onclick="retryNotify('${entry.id}')" id="retry-${entry.id}">Retry</button>`
196
+ : '';
197
+
198
+ notificationSection = `
199
+ <div class="notification-status" id="notify-status-${entry.id}">
200
+ ${notifyStatus} ${retryBtn}
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ return `
206
+ <div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}" data-notified="${entry.notified ? '1' : '0'}">
207
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
208
+ <div class="entry-header">
209
+ <strong>${entry.service}</strong> / ${entry.account_name}
210
+ <span class="status-badge">${statusBadge(entry.status)}</span>
211
+ </div>
212
+ <div style="display: flex; align-items: center; gap: 12px;">
213
+ <span class="help" style="margin: 0;">${formatDate(entry.submitted_at)}</span>
214
+ <button type="button" class="delete-btn" onclick="deleteEntry('${entry.id}')" title="Delete">&times;</button>
215
+ </div>
216
+ </div>
217
+
218
+ ${entry.comment ? `<p class="agent-comment"><strong>Agent says:</strong> ${renderMarkdownLinks(entry.comment)}</p>` : ''}
219
+
220
+ <div class="help" style="margin-bottom: 8px;">Submitted by: <code>${escapeHtml(entry.submitted_by || 'unknown')}</code></div>
221
+
222
+ <div class="requests-list">
223
+ ${requestsSummary}
224
+ </div>
225
+
226
+ <details style="margin-top: 12px;">
227
+ <summary>Request Details</summary>
228
+ <pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.requests, null, 2))}</pre>
229
+ </details>
230
+
231
+ ${resultSection}
232
+ ${notificationSection}
233
+ ${actions}
234
+ </div>
235
+ `;
236
+ };
237
+
238
+ const filters = ['all', 'pending', 'completed', 'failed', 'rejected'];
239
+ const filterLinks = filters.map(f =>
240
+ `<a href="/ui/queue?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
241
+ ).join('');
242
+
243
+ return `<!DOCTYPE html>
244
+ <html>
245
+ <head>
246
+ <title>agentgate - Write Queue</title>
247
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
248
+ <link rel="stylesheet" href="/public/style.css">
249
+ <style>
250
+ .filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
251
+ .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; }
252
+ .filter-link:hover { background: rgba(255, 255, 255, 0.1); color: var(--gray-200); border-color: rgba(255, 255, 255, 0.2); }
253
+ .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); }
254
+ .queue-entry { margin-bottom: 20px; }
255
+ .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; }
256
+ .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; }
257
+ .request-item span { color: var(--gray-300); }
258
+ .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; }
259
+ .back-link { color: #818cf8; text-decoration: none; font-weight: 600; transition: color 0.2s ease; }
260
+ .back-link:hover { color: #ffffff; }
261
+ .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; }
262
+ .delete-btn:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); }
263
+ .clear-section { margin-left: auto; display: flex; gap: 10px; }
264
+ .entry-header { display: flex; align-items: center; gap: 12px; }
265
+ .entry-header strong { color: #f3f4f6; font-size: 16px; }
266
+ .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; }
267
+ .reject-input:focus { outline: none; border-color: #f87171; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); }
268
+ .reject-input::placeholder { color: #6b7280; }
269
+ .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; }
270
+ .agent-comment strong { color: #818cf8; }
271
+ .agent-comment a { color: #818cf8; }
272
+ .rejection-reason { margin-top: 16px; padding: 16px; background: rgba(239, 68, 68, 0.1); border-radius: 10px; border-left: 4px solid #f87171; color: #e5e7eb; }
273
+ .rejection-reason strong { color: #f87171; }
274
+ .empty-state { text-align: center; padding: 60px 40px; }
275
+ .empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
276
+ .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; }
277
+ .notify-status { padding: 4px 10px; border-radius: 6px; font-weight: 500; }
278
+ .notify-sent { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
279
+ .notify-failed { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
280
+ .notify-pending { background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3); }
281
+ .btn-link { background: none; border: none; color: #818cf8; cursor: pointer; text-decoration: underline; padding: 4px 8px; font-size: 13px; }
282
+ .btn-link:hover { color: #a5b4fc; }
283
+ </style>
284
+ </head>
285
+ <body>
286
+ <div style="display: flex; justify-content: space-between; align-items: center;">
287
+ <h1>Write Queue</h1>
288
+ <a href="/ui" class="back-link">&larr; Back to Dashboard</a>
289
+ </div>
290
+ <p>Review and approve write requests from agents.</p>
291
+
292
+ <div class="filter-bar" id="filter-bar">
293
+ ${filterLinks}
294
+ <div class="clear-section">
295
+ ${filter === 'completed' && counts.completed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'completed\')">Clear Completed</button>' : ''}
296
+ ${filter === 'failed' && counts.failed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'failed\')">Clear Failed</button>' : ''}
297
+ ${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
298
+ ${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>' : ''}
299
+ </div>
300
+ </div>
301
+
302
+ <div id="entries-container">
303
+ ${entries.length === 0 ? `
304
+ <div class="card empty-state">
305
+ <p>No ${filter === 'all' ? '' : filter + ' '}requests in queue</p>
306
+ </div>
307
+ ` : entries.map(renderEntry).join('')}
308
+ </div>
309
+
310
+ <script>
311
+ function escapeHtml(str) {
312
+ if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
313
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
314
+ }
315
+
316
+ async function approveEntry(id) {
317
+ const btn = event.target;
318
+ btn.disabled = true;
319
+ btn.textContent = 'Approving...';
320
+ try {
321
+ const res = await fetch('/ui/queue/' + id + '/approve', { method: 'POST', headers: { 'Accept': 'application/json' } });
322
+ const data = await res.json();
323
+ if (data.success) {
324
+ window.location.reload();
325
+ } else {
326
+ alert(data.error || 'Failed to approve');
327
+ btn.disabled = false;
328
+ btn.textContent = 'Approve';
329
+ }
330
+ } catch (err) {
331
+ alert('Error: ' + err.message);
332
+ btn.disabled = false;
333
+ btn.textContent = 'Approve';
334
+ }
335
+ }
336
+
337
+ async function rejectEntry(id) {
338
+ const btn = event.target;
339
+ const reasonInput = document.getElementById('reason-' + id);
340
+ const reason = reasonInput ? reasonInput.value : '';
341
+ btn.disabled = true;
342
+ btn.textContent = 'Rejecting...';
343
+ try {
344
+ const res = await fetch('/ui/queue/' + id + '/reject', {
345
+ method: 'POST',
346
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
347
+ body: JSON.stringify({ reason })
348
+ });
349
+ const data = await res.json();
350
+ if (data.success) {
351
+ window.location.reload();
352
+ } else {
353
+ alert(data.error || 'Failed to reject');
354
+ btn.disabled = false;
355
+ btn.textContent = 'Reject';
356
+ }
357
+ } catch (err) {
358
+ alert('Error: ' + err.message);
359
+ btn.disabled = false;
360
+ btn.textContent = 'Reject';
361
+ }
362
+ }
363
+
364
+ async function clearByStatus(status) {
365
+ const btn = event.target;
366
+ btn.disabled = true;
367
+ btn.textContent = 'Clearing...';
368
+ try {
369
+ const res = await fetch('/ui/queue/clear', {
370
+ method: 'POST',
371
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ status })
373
+ });
374
+ const data = await res.json();
375
+ if (data.success) {
376
+ window.location.reload();
377
+ }
378
+ } catch (err) {
379
+ alert('Error: ' + err.message);
380
+ btn.disabled = false;
381
+ }
382
+ }
383
+
384
+ async function deleteEntry(id) {
385
+ if (!confirm('Delete this queue entry?')) return;
386
+ try {
387
+ const res = await fetch('/ui/queue/' + id, { method: 'DELETE', headers: { 'Accept': 'application/json' } });
388
+ const data = await res.json();
389
+ if (data.success) {
390
+ document.getElementById('entry-' + id)?.remove();
391
+ const container = document.getElementById('entries-container');
392
+ if (container.querySelectorAll('.queue-entry').length === 0) {
393
+ container.innerHTML = '<div class="card empty-state"><p>No requests in queue</p></div>';
394
+ }
395
+ } else {
396
+ alert(data.error || 'Failed to delete');
397
+ }
398
+ } catch (err) {
399
+ alert('Error: ' + err.message);
400
+ }
401
+ }
402
+
403
+ async function retryNotify(id) {
404
+ const btn = document.getElementById('retry-' + id);
405
+ if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
406
+ try {
407
+ const res = await fetch('/ui/queue/' + id + '/notify', { method: 'POST', headers: { 'Accept': 'application/json' } });
408
+ const data = await res.json();
409
+ if (data.success) window.location.reload();
410
+ } catch (err) {
411
+ alert('Error: ' + err.message);
412
+ if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
413
+ }
414
+ }
415
+ </script>
416
+ </body>
417
+ </html>`;
418
+ }
419
+
420
+ export default router;
@@ -0,0 +1,59 @@
1
+ // Settings routes - hsync, messaging mode, queue settings
2
+ import { Router } from 'express';
3
+ import {
4
+ setSetting, deleteSetting,
5
+ setMessagingMode,
6
+ setSharedQueueVisibility,
7
+ setAgentWithdrawEnabled
8
+ } from '../../lib/db.js';
9
+ import { connectHsync, disconnectHsync } from '../../lib/hsyncManager.js';
10
+ import { PORT } from './shared.js';
11
+
12
+ const router = Router();
13
+
14
+ // hsync setup
15
+ router.post('/hsync/setup', async (req, res) => {
16
+ const { url, token } = req.body;
17
+ if (!url) {
18
+ return res.status(400).send('URL required');
19
+ }
20
+ setSetting('hsync', {
21
+ url: url.replace(/\/$/, ''),
22
+ token: token || '',
23
+ enabled: true
24
+ });
25
+ await connectHsync(PORT);
26
+ res.redirect('/ui');
27
+ });
28
+
29
+ router.post('/hsync/delete', async (req, res) => {
30
+ await disconnectHsync();
31
+ deleteSetting('hsync');
32
+ res.redirect('/ui');
33
+ });
34
+
35
+ // Agent Messaging settings
36
+ router.post('/messaging/mode', (req, res) => {
37
+ const { mode } = req.body;
38
+ try {
39
+ setMessagingMode(mode);
40
+ res.redirect('/ui');
41
+ } catch (err) {
42
+ res.status(400).send(err.message);
43
+ }
44
+ });
45
+
46
+ // Queue Settings
47
+ router.post('/queue/settings/shared-visibility', (req, res) => {
48
+ const enabled = req.body.enabled === 'true' || req.body.enabled === '1';
49
+ setSharedQueueVisibility(enabled);
50
+ res.redirect('/ui');
51
+ });
52
+
53
+ router.post('/queue/settings/agent-withdraw', (req, res) => {
54
+ const enabled = req.body.enabled === 'true' || req.body.enabled === '1';
55
+ setAgentWithdrawEnabled(enabled);
56
+ res.redirect('/ui');
57
+ });
58
+
59
+ export default router;
@@ -0,0 +1,139 @@
1
+ // Shared constants and helpers for UI routes
2
+
3
+ export const AUTH_COOKIE = 'rms_auth';
4
+ export const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 1 week
5
+
6
+ export const PORT = process.env.PORT || 3050;
7
+ export const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
8
+
9
+ // HTML escape helper
10
+ export function escapeHtml(str) {
11
+ if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
12
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
+ }
14
+
15
+ // Render markdown links [text](url) while escaping everything else
16
+ export function renderMarkdownLinks(str) {
17
+ if (!str) return '';
18
+ let escaped = escapeHtml(str);
19
+ escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
20
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
21
+ return escaped;
22
+ }
23
+
24
+ // Status badge HTML
25
+ export function statusBadge(status) {
26
+ const colors = {
27
+ pending: 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3);',
28
+ approved: 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3);',
29
+ executing: 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3);',
30
+ completed: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
31
+ failed: 'background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3);',
32
+ rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);',
33
+ delivered: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);'
34
+ };
35
+ return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
36
+ }
37
+
38
+ // Format date for display
39
+ export function formatDate(dateStr) {
40
+ if (!dateStr) return '';
41
+ const d = new Date(dateStr);
42
+ return d.toLocaleString();
43
+ }
44
+
45
+ // Shared HTML head with common styles/scripts
46
+ export function htmlHead(title, { includeSocket = false } = {}) {
47
+ return `<!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <title>agentgate - ${title}</title>
51
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
52
+ <link rel="stylesheet" href="/public/style.css">
53
+ ${includeSocket ? '<script src="/socket.io/socket.io.js"></script>' : ''}
54
+ </head>`;
55
+ }
56
+
57
+ // Navigation header
58
+ export function navHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, messagingMode = 'off' } = {}) {
59
+ return `
60
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
61
+ <div style="display: flex; align-items: center; gap: 12px;">
62
+ <img src="/public/favicon.svg" alt="agentgate" style="height: 64px;">
63
+ <h1 style="margin: 0;">agentgate</h1>
64
+ </div>
65
+ <div style="display: flex; gap: 12px; align-items: center;">
66
+ <a href="/ui/keys" class="nav-btn nav-btn-default">API Keys</a>
67
+ <a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
68
+ Write Queue
69
+ <span id="queue-badge" class="badge" ${pendingQueueCount > 0 ? '' : 'style="display:none"'}>${pendingQueueCount}</span>
70
+ </a>
71
+ <a href="/ui/messages" id="messages-nav" class="nav-btn nav-btn-default" style="position: relative;${messagingMode === 'off' ? ' display:none;' : ''}">
72
+ Messages
73
+ <span id="messages-badge" class="badge" ${pendingMessagesCount > 0 ? '' : 'style="display:none"'}>${pendingMessagesCount}</span>
74
+ </a>
75
+ <div class="nav-divider"></div>
76
+ <form method="POST" action="/ui/logout" style="margin: 0;">
77
+ <button type="submit" class="nav-btn nav-btn-default" style="color: #f87171;">Logout</button>
78
+ </form>
79
+ </div>
80
+ </div>`;
81
+ }
82
+
83
+ // Socket.io client script for real-time badge updates
84
+ export function socketScript() {
85
+ return `
86
+ <script>
87
+ document.addEventListener('DOMContentLoaded', function() {
88
+ const socket = io();
89
+
90
+ socket.on('counts', function(data) {
91
+ // Update queue badge
92
+ const queueBadge = document.getElementById('queue-badge');
93
+ if (queueBadge) {
94
+ if (data.queue.pending > 0) {
95
+ queueBadge.textContent = data.queue.pending;
96
+ queueBadge.style.display = '';
97
+ } else {
98
+ queueBadge.style.display = 'none';
99
+ }
100
+ }
101
+
102
+ // Update messages badge
103
+ const msgBadge = document.getElementById('messages-badge');
104
+ if (msgBadge) {
105
+ if (data.messages.pending > 0) {
106
+ msgBadge.textContent = data.messages.pending;
107
+ msgBadge.style.display = '';
108
+ } else {
109
+ msgBadge.style.display = 'none';
110
+ }
111
+ }
112
+
113
+ // Show/hide messages nav based on messaging mode
114
+ const msgNav = document.getElementById('messages-nav');
115
+ if (msgNav) {
116
+ msgNav.style.display = data.messagingEnabled ? '' : 'none';
117
+ }
118
+ });
119
+
120
+ socket.on('connect', function() {
121
+ console.log('Socket.io connected for real-time updates');
122
+ });
123
+ });
124
+ </script>`;
125
+ }
126
+
127
+ // Copy text helper script
128
+ export function copyScript() {
129
+ return `
130
+ <script>
131
+ function copyText(text, btn) {
132
+ navigator.clipboard.writeText(text).then(() => {
133
+ const orig = btn.textContent;
134
+ btn.textContent = 'Copied!';
135
+ setTimeout(() => btn.textContent = orig, 1500);
136
+ });
137
+ }
138
+ </script>`;
139
+ }