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.
- package/package.json +1 -1
- package/src/index.js +76 -23
- package/src/lib/db.js +43 -1
- package/src/lib/socketManager.js +73 -0
- package/src/routes/agents.js +73 -0
- package/src/routes/queue.js +78 -3
- package/src/routes/ui/auth.js +149 -0
- package/src/routes/ui/keys.js +302 -0
- package/src/routes/ui/messages.js +451 -0
- package/src/routes/ui/queue.js +420 -0
- package/src/routes/ui/settings.js +59 -0
- package/src/routes/ui/shared.js +139 -0
- package/src/routes/ui-new.js +196 -0
- package/src/routes/ui.js +260 -10
|
@@ -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">×</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">← 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|