agentgate 0.1.8 → 0.2.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 +24 -0
- package/package.json +1 -1
- package/public/style.css +9 -0
- package/src/index.js +23 -2
- package/src/lib/db.js +60 -3
- package/src/lib/notifier.js +286 -0
- package/src/lib/queueExecutor.js +18 -6
- package/src/routes/ui.js +331 -7
package/README.md
CHANGED
|
@@ -109,7 +109,31 @@ curl -H "Authorization: Bearer rms_your_key" \
|
|
|
109
109
|
https://your-server.com/api/skill > SKILL.md
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
### Webhook Notifications
|
|
113
|
+
|
|
114
|
+
agentgate can notify your agent when queue items are completed, failed, or rejected — closing the feedback loop so your agent knows immediately when requests are processed.
|
|
115
|
+
|
|
116
|
+
**Setup in Admin UI:**
|
|
117
|
+
1. Go to **Advanced → Clawdbot Notifications**
|
|
118
|
+
2. Enter your OpenClaw/Clawdbot webhook URL (e.g., `https://your-gateway.example.com/hooks/wake`)
|
|
119
|
+
3. Enter the hooks token from your OpenClaw config
|
|
120
|
+
4. Choose events: `completed`, `failed`, `rejected`
|
|
121
|
+
|
|
122
|
+
**Features:**
|
|
123
|
+
- **Real-time notifications** — Instant webhook on each approval/rejection
|
|
124
|
+
- **Notification status** — See ✓ notified / ⚠ failed on each queue entry
|
|
125
|
+
- **Retry individual** — Resend failed notifications one at a time
|
|
126
|
+
- **Batch catch-up** — "Retry All" sends missed notifications in one batched message
|
|
127
|
+
|
|
128
|
+
**Webhook payload:**
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"text": "✅ [agentgate] Queue #abc123 completed\n→ github/monteslu\n→ https://github.com/...\nOriginal: \"Create PR for fix\"",
|
|
132
|
+
"mode": "now"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
112
135
|
|
|
136
|
+
Compatible with OpenClaw's `/hooks/wake` endpoint. See [OpenClaw webhook docs](https://docs.openclaw.ai/automation/webhook).
|
|
113
137
|
|
|
114
138
|
## API Key Management
|
|
115
139
|
|
package/package.json
CHANGED
package/public/style.css
CHANGED
|
@@ -582,3 +582,12 @@ body > p:first-of-type {
|
|
|
582
582
|
background: rgba(99, 102, 241, 0.3);
|
|
583
583
|
color: white;
|
|
584
584
|
}
|
|
585
|
+
|
|
586
|
+
/* Success message */
|
|
587
|
+
.success-message {
|
|
588
|
+
background: rgba(16, 185, 129, 0.15);
|
|
589
|
+
color: #34d399;
|
|
590
|
+
padding: 12px 16px;
|
|
591
|
+
border-radius: 8px;
|
|
592
|
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
593
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import express from 'express';
|
|
|
2
2
|
import cookieParser from 'cookie-parser';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
|
-
import { validateApiKey, getAccountsByService, getCookieSecret } from './lib/db.js';
|
|
5
|
+
import { validateApiKey, getAccountsByService, getCookieSecret, getSetting } from './lib/db.js';
|
|
6
6
|
import { connectHsync } from './lib/hsyncManager.js';
|
|
7
7
|
import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
|
|
8
8
|
import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
|
|
@@ -126,7 +126,7 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
126
126
|
description: 'For write operations (POST/PUT/DELETE), you must submit requests to the write queue. A human will review and approve or reject your request. You cannot execute write operations directly.',
|
|
127
127
|
workflow: [
|
|
128
128
|
'1. Submit your write request(s) with a comment explaining your intent',
|
|
129
|
-
'2.
|
|
129
|
+
'2. Wait for webhook notification OR poll the status endpoint',
|
|
130
130
|
'3. If rejected, check rejection_reason and adjust your approach',
|
|
131
131
|
'4. If approved and completed, results contain the API responses',
|
|
132
132
|
'5. If failed, results show which request failed and why'
|
|
@@ -205,6 +205,27 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
},
|
|
208
|
+
notifications: (() => {
|
|
209
|
+
const config = getSetting('notifications');
|
|
210
|
+
const enabled = config?.clawdbot?.enabled || false;
|
|
211
|
+
return {
|
|
212
|
+
enabled,
|
|
213
|
+
description: enabled
|
|
214
|
+
? 'Webhook notifications are ENABLED. You will receive automatic notifications when queue items complete, fail, or are rejected. No need to poll.'
|
|
215
|
+
: 'Webhook notifications are NOT configured. You must poll the status endpoint to check request status.',
|
|
216
|
+
setup: 'Configure in Admin UI under Advanced → Clawdbot Notifications',
|
|
217
|
+
webhookFormat: {
|
|
218
|
+
description: 'POST to your webhook URL with JSON body',
|
|
219
|
+
payload: {
|
|
220
|
+
text: 'Notification message with status, service, result URL, and original comment',
|
|
221
|
+
mode: 'now'
|
|
222
|
+
},
|
|
223
|
+
example: '✅ [agentgate] Queue #abc123 completed\\n→ github/monteslu\\n→ https://github.com/...\\nOriginal: "Create PR"'
|
|
224
|
+
},
|
|
225
|
+
events: enabled ? (config.clawdbot.events || ['completed', 'failed']) : ['completed', 'failed', 'rejected'],
|
|
226
|
+
compatible: 'OpenClaw/Clawdbot /hooks/wake endpoint'
|
|
227
|
+
};
|
|
228
|
+
})(),
|
|
208
229
|
skill: {
|
|
209
230
|
description: 'Generate a SKILL.md file for OpenClaw/AgentSkills compatible systems',
|
|
210
231
|
endpoint: 'GET /api/skill',
|
package/src/lib/db.js
CHANGED
|
@@ -42,10 +42,34 @@ db.exec(`
|
|
|
42
42
|
submitted_by TEXT,
|
|
43
43
|
submitted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
44
44
|
reviewed_at TEXT,
|
|
45
|
-
completed_at TEXT
|
|
45
|
+
completed_at TEXT,
|
|
46
|
+
notified INTEGER DEFAULT 0,
|
|
47
|
+
notified_at TEXT,
|
|
48
|
+
notify_error TEXT
|
|
46
49
|
);
|
|
47
50
|
`);
|
|
48
51
|
|
|
52
|
+
// Migrate write_queue table to add notification columns
|
|
53
|
+
try {
|
|
54
|
+
const queueInfo = db.prepare('PRAGMA table_info(write_queue)').all();
|
|
55
|
+
const hasNotified = queueInfo.some(col => col.name === 'notified');
|
|
56
|
+
|
|
57
|
+
if (queueInfo.length > 0 && !hasNotified) {
|
|
58
|
+
console.log('Migrating write_queue table to add notification columns...');
|
|
59
|
+
db.exec(`
|
|
60
|
+
ALTER TABLE write_queue ADD COLUMN notified INTEGER DEFAULT 0;
|
|
61
|
+
ALTER TABLE write_queue ADD COLUMN notified_at TEXT;
|
|
62
|
+
ALTER TABLE write_queue ADD COLUMN notify_error TEXT;
|
|
63
|
+
`);
|
|
64
|
+
console.log('Migration complete.');
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Columns might already exist or table doesn't exist yet
|
|
68
|
+
if (!err.message.includes('duplicate column')) {
|
|
69
|
+
console.error('Error migrating write_queue:', err.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
// Initialize api_keys table with migration support for old schema
|
|
50
74
|
// Old schema had: id, name, key, created_at
|
|
51
75
|
// New schema has: id, name, key_prefix, key_hash, created_at
|
|
@@ -228,7 +252,8 @@ export function getQueueEntry(id) {
|
|
|
228
252
|
return {
|
|
229
253
|
...row,
|
|
230
254
|
requests: JSON.parse(row.requests),
|
|
231
|
-
results: row.results ? JSON.parse(row.results) : null
|
|
255
|
+
results: row.results ? JSON.parse(row.results) : null,
|
|
256
|
+
notified: Boolean(row.notified)
|
|
232
257
|
};
|
|
233
258
|
}
|
|
234
259
|
|
|
@@ -242,7 +267,39 @@ export function listQueueEntries(status) {
|
|
|
242
267
|
return rows.map(row => ({
|
|
243
268
|
...row,
|
|
244
269
|
requests: JSON.parse(row.requests),
|
|
245
|
-
results: row.results ? JSON.parse(row.results) : null
|
|
270
|
+
results: row.results ? JSON.parse(row.results) : null,
|
|
271
|
+
notified: Boolean(row.notified)
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function updateQueueNotification(id, success, error = null) {
|
|
276
|
+
if (success) {
|
|
277
|
+
db.prepare(`
|
|
278
|
+
UPDATE write_queue
|
|
279
|
+
SET notified = 1, notified_at = CURRENT_TIMESTAMP, notify_error = NULL
|
|
280
|
+
WHERE id = ?
|
|
281
|
+
`).run(id);
|
|
282
|
+
} else {
|
|
283
|
+
db.prepare(`
|
|
284
|
+
UPDATE write_queue
|
|
285
|
+
SET notified = 0, notify_error = ?
|
|
286
|
+
WHERE id = ?
|
|
287
|
+
`).run(error, id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function listUnnotifiedEntries() {
|
|
292
|
+
const rows = db.prepare(`
|
|
293
|
+
SELECT * FROM write_queue
|
|
294
|
+
WHERE status IN ('completed', 'failed', 'rejected')
|
|
295
|
+
AND notified = 0
|
|
296
|
+
ORDER BY completed_at DESC
|
|
297
|
+
`).all();
|
|
298
|
+
return rows.map(row => ({
|
|
299
|
+
...row,
|
|
300
|
+
requests: JSON.parse(row.requests),
|
|
301
|
+
results: row.results ? JSON.parse(row.results) : null,
|
|
302
|
+
notified: false
|
|
246
303
|
}));
|
|
247
304
|
}
|
|
248
305
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { getSetting, updateQueueNotification } from './db.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send a notification to Clawdbot webhook when queue items complete/fail
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
8
|
+
const DEFAULT_RETRY_DELAY_MS = 5000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find the most relevant result to display (usually last one with a URL, or first failure)
|
|
12
|
+
*/
|
|
13
|
+
function findRelevantResult(results, forError = false) {
|
|
14
|
+
if (!results?.length) return null;
|
|
15
|
+
|
|
16
|
+
if (forError) {
|
|
17
|
+
// For errors, find the first non-ok result
|
|
18
|
+
return results.find(r => !r.ok) || results[results.length - 1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// For success, find the last result with a useful URL (PR, issue, etc.)
|
|
22
|
+
// Search backwards to get the most relevant one (e.g., PR URL, not branch ref)
|
|
23
|
+
for (let i = results.length - 1; i >= 0; i--) {
|
|
24
|
+
const r = results[i];
|
|
25
|
+
if (r.body?.html_url || r.body?.url) {
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback to last result
|
|
31
|
+
return results[results.length - 1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format the notification text for a queue entry
|
|
36
|
+
*/
|
|
37
|
+
function formatNotification(entry) {
|
|
38
|
+
const emoji = entry.status === 'completed' ? '✅' :
|
|
39
|
+
entry.status === 'failed' ? '❌' : '🚫';
|
|
40
|
+
|
|
41
|
+
let text = `${emoji} [agentgate] Queue #${entry.id} ${entry.status}`;
|
|
42
|
+
text += `\n→ ${entry.service}/${entry.account_name}`;
|
|
43
|
+
|
|
44
|
+
// Include key result info (e.g., PR URL, issue URL)
|
|
45
|
+
if (entry.status === 'completed' && entry.results?.length) {
|
|
46
|
+
const relevantResult = findRelevantResult(entry.results, false);
|
|
47
|
+
if (relevantResult?.body) {
|
|
48
|
+
// GitHub PR/Issue
|
|
49
|
+
if (relevantResult.body.html_url) {
|
|
50
|
+
text += `\n→ ${relevantResult.body.html_url}`;
|
|
51
|
+
}
|
|
52
|
+
// Other useful fields
|
|
53
|
+
else if (relevantResult.body.url) {
|
|
54
|
+
text += `\n→ ${relevantResult.body.url}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Include error info for failures
|
|
60
|
+
if (entry.status === 'failed' && entry.results?.length) {
|
|
61
|
+
const failingResult = findRelevantResult(entry.results, true);
|
|
62
|
+
if (failingResult.error) {
|
|
63
|
+
text += `\n→ Error: ${failingResult.error}`;
|
|
64
|
+
} else if (failingResult.body?.message) {
|
|
65
|
+
text += `\n→ Error: ${failingResult.body.message}`;
|
|
66
|
+
} else if (!failingResult.ok && failingResult.status) {
|
|
67
|
+
text += `\n→ Error: HTTP ${failingResult.status}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Include rejection reason
|
|
72
|
+
if (entry.status === 'rejected' && entry.rejection_reason) {
|
|
73
|
+
text += `\n→ Reason: ${entry.rejection_reason}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Original comment/intent
|
|
77
|
+
if (entry.comment) {
|
|
78
|
+
// Truncate long comments
|
|
79
|
+
const comment = entry.comment.length > 100
|
|
80
|
+
? entry.comment.substring(0, 100) + '...'
|
|
81
|
+
: entry.comment;
|
|
82
|
+
text += `\nOriginal: "${comment}"`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sleep helper
|
|
90
|
+
*/
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Send notification to Clawdbot
|
|
97
|
+
* @param {object} entry - The queue entry to notify about
|
|
98
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
99
|
+
*/
|
|
100
|
+
export async function notifyClawdbot(entry) {
|
|
101
|
+
const config = getSetting('notifications');
|
|
102
|
+
|
|
103
|
+
if (!config?.clawdbot?.enabled) {
|
|
104
|
+
return { success: true, skipped: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { url, token, events, retryAttempts, retryDelayMs } = config.clawdbot;
|
|
108
|
+
|
|
109
|
+
// Check if this event type should be notified
|
|
110
|
+
const allowedEvents = events || ['completed', 'failed'];
|
|
111
|
+
if (!allowedEvents.includes(entry.status)) {
|
|
112
|
+
return { success: true, skipped: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!url || !token) {
|
|
116
|
+
const error = 'Clawdbot notification config incomplete (missing url or token)';
|
|
117
|
+
updateQueueNotification(entry.id, false, error);
|
|
118
|
+
return { success: false, error };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const payload = {
|
|
122
|
+
text: formatNotification(entry),
|
|
123
|
+
mode: 'now'
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const maxAttempts = retryAttempts || DEFAULT_RETRY_ATTEMPTS;
|
|
127
|
+
const delay = retryDelayMs || DEFAULT_RETRY_DELAY_MS;
|
|
128
|
+
let lastError = null;
|
|
129
|
+
|
|
130
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Authorization': `Bearer ${token}`,
|
|
136
|
+
'Content-Type': 'application/json'
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(payload)
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (response.ok) {
|
|
142
|
+
// Success
|
|
143
|
+
updateQueueNotification(entry.id, true, null);
|
|
144
|
+
return { success: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Non-OK response
|
|
148
|
+
const text = await response.text().catch(() => '');
|
|
149
|
+
lastError = `HTTP ${response.status}: ${text.substring(0, 100)}`;
|
|
150
|
+
|
|
151
|
+
} catch (err) {
|
|
152
|
+
lastError = err.message;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Retry after delay (unless last attempt)
|
|
156
|
+
if (attempt < maxAttempts) {
|
|
157
|
+
await sleep(delay);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// All retries failed
|
|
162
|
+
updateQueueNotification(entry.id, false, lastError);
|
|
163
|
+
return { success: false, error: lastError };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Retry notification for a specific queue entry
|
|
168
|
+
*/
|
|
169
|
+
export async function retryNotification(entryId, getQueueEntry) {
|
|
170
|
+
const entry = getQueueEntry(entryId);
|
|
171
|
+
if (!entry) {
|
|
172
|
+
return { success: false, error: 'Queue entry not found' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Only retry for completed/failed/rejected entries
|
|
176
|
+
if (!['completed', 'failed', 'rejected'].includes(entry.status)) {
|
|
177
|
+
return { success: false, error: 'Can only notify for completed/failed/rejected entries' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return notifyClawdbot(entry);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Format a short summary line for batch notifications
|
|
185
|
+
*/
|
|
186
|
+
function formatBatchLine(entry) {
|
|
187
|
+
const emoji = entry.status === 'completed' ? '✅' :
|
|
188
|
+
entry.status === 'failed' ? '❌' : '🚫';
|
|
189
|
+
|
|
190
|
+
let line = `${emoji} #${entry.id.substring(0, 8)} - ${entry.service}/${entry.account_name}`;
|
|
191
|
+
|
|
192
|
+
// Add brief result info
|
|
193
|
+
if (entry.status === 'completed') {
|
|
194
|
+
const relevantResult = findRelevantResult(entry.results, false);
|
|
195
|
+
if (relevantResult?.body?.html_url) {
|
|
196
|
+
line += ` - ${relevantResult.body.html_url}`;
|
|
197
|
+
}
|
|
198
|
+
} else if (entry.status === 'failed') {
|
|
199
|
+
const failingResult = findRelevantResult(entry.results, true);
|
|
200
|
+
const err = failingResult?.error || failingResult?.body?.message || (failingResult && !failingResult.ok ? `HTTP ${failingResult.status || '?'}` : 'Unknown error');
|
|
201
|
+
line += ` - ${err.substring(0, 50)}`;
|
|
202
|
+
} else if (entry.status === 'rejected') {
|
|
203
|
+
line += ` - ${(entry.rejection_reason || 'rejected').substring(0, 50)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Send a single batched notification for multiple queue entries
|
|
211
|
+
* @param {object[]} entries - Array of queue entries to notify about
|
|
212
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
213
|
+
*/
|
|
214
|
+
export async function notifyClawdbotBatch(entries) {
|
|
215
|
+
const config = getSetting('notifications');
|
|
216
|
+
|
|
217
|
+
if (!config?.clawdbot?.enabled) {
|
|
218
|
+
return { success: true, skipped: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { url, token, retryAttempts, retryDelayMs } = config.clawdbot;
|
|
222
|
+
|
|
223
|
+
if (!url || !token) {
|
|
224
|
+
const error = 'Clawdbot notification config incomplete (missing url or token)';
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
updateQueueNotification(entry.id, false, error);
|
|
227
|
+
}
|
|
228
|
+
return { success: false, error };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Build batched message
|
|
232
|
+
const completed = entries.filter(e => e.status === 'completed').length;
|
|
233
|
+
const failed = entries.filter(e => e.status === 'failed').length;
|
|
234
|
+
const rejected = entries.filter(e => e.status === 'rejected').length;
|
|
235
|
+
|
|
236
|
+
const counts = [];
|
|
237
|
+
if (completed) counts.push(`${completed} completed`);
|
|
238
|
+
if (failed) counts.push(`${failed} failed`);
|
|
239
|
+
if (rejected) counts.push(`${rejected} rejected`);
|
|
240
|
+
|
|
241
|
+
let text = `📬 [agentgate] Catch-up: ${entries.length} item${entries.length > 1 ? 's' : ''} (${counts.join(', ')})\n\n`;
|
|
242
|
+
text += entries.map(formatBatchLine).join('\n');
|
|
243
|
+
|
|
244
|
+
const payload = { text, mode: 'now' };
|
|
245
|
+
|
|
246
|
+
const maxAttempts = retryAttempts || DEFAULT_RETRY_ATTEMPTS;
|
|
247
|
+
const delay = retryDelayMs || DEFAULT_RETRY_DELAY_MS;
|
|
248
|
+
let lastError = null;
|
|
249
|
+
|
|
250
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(url, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
'Authorization': `Bearer ${token}`,
|
|
256
|
+
'Content-Type': 'application/json'
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify(payload)
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (response.ok) {
|
|
262
|
+
// Success - mark all as notified
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
updateQueueNotification(entry.id, true, null);
|
|
265
|
+
}
|
|
266
|
+
return { success: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const respText = await response.text().catch(() => '');
|
|
270
|
+
lastError = `HTTP ${response.status}: ${respText.substring(0, 100)}`;
|
|
271
|
+
|
|
272
|
+
} catch (err) {
|
|
273
|
+
lastError = err.message;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (attempt < maxAttempts) {
|
|
277
|
+
await sleep(delay);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// All retries failed
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
updateQueueNotification(entry.id, false, lastError);
|
|
284
|
+
}
|
|
285
|
+
return { success: false, error: lastError };
|
|
286
|
+
}
|
package/src/lib/queueExecutor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getAccountCredentials, setAccountCredentials, updateQueueStatus } from './db.js';
|
|
1
|
+
import { getAccountCredentials, setAccountCredentials, updateQueueStatus, getQueueEntry } from './db.js';
|
|
2
|
+
import { notifyClawdbot } from './notifier.js';
|
|
2
3
|
|
|
3
4
|
// Service base URLs
|
|
4
5
|
const SERVICE_URLS = {
|
|
@@ -270,6 +271,17 @@ function buildHeaders(service, token, customHeaders = {}) {
|
|
|
270
271
|
return headers;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// Helper to update status and send notification
|
|
275
|
+
function finalizeEntry(entryId, status, results) {
|
|
276
|
+
updateQueueStatus(entryId, status, { results });
|
|
277
|
+
|
|
278
|
+
// Send notification to Clawdbot (async, don't block)
|
|
279
|
+
const updatedEntry = getQueueEntry(entryId);
|
|
280
|
+
notifyClawdbot(updatedEntry).catch(err => {
|
|
281
|
+
console.error('[notifier] Failed to notify Clawdbot:', err.message);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
273
285
|
// Execute a single queued entry (batch of requests)
|
|
274
286
|
export async function executeQueueEntry(entry) {
|
|
275
287
|
const results = [];
|
|
@@ -290,7 +302,7 @@ export async function executeQueueEntry(entry) {
|
|
|
290
302
|
ok: false,
|
|
291
303
|
error: `Failed to get access token for ${service}/${account_name}`
|
|
292
304
|
});
|
|
293
|
-
|
|
305
|
+
finalizeEntry(entry.id, 'failed', results);
|
|
294
306
|
return { success: false, results };
|
|
295
307
|
}
|
|
296
308
|
|
|
@@ -302,7 +314,7 @@ export async function executeQueueEntry(entry) {
|
|
|
302
314
|
ok: false,
|
|
303
315
|
error: `Unknown service or invalid configuration: ${service}`
|
|
304
316
|
});
|
|
305
|
-
|
|
317
|
+
finalizeEntry(entry.id, 'failed', results);
|
|
306
318
|
return { success: false, results };
|
|
307
319
|
}
|
|
308
320
|
|
|
@@ -346,7 +358,7 @@ export async function executeQueueEntry(entry) {
|
|
|
346
358
|
|
|
347
359
|
// Stop on first failure
|
|
348
360
|
if (!response.ok) {
|
|
349
|
-
|
|
361
|
+
finalizeEntry(entry.id, 'failed', results);
|
|
350
362
|
return { success: false, results };
|
|
351
363
|
}
|
|
352
364
|
|
|
@@ -356,12 +368,12 @@ export async function executeQueueEntry(entry) {
|
|
|
356
368
|
ok: false,
|
|
357
369
|
error: err.message
|
|
358
370
|
});
|
|
359
|
-
|
|
371
|
+
finalizeEntry(entry.id, 'failed', results);
|
|
360
372
|
return { success: false, results };
|
|
361
373
|
}
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
// All requests succeeded
|
|
365
|
-
|
|
377
|
+
finalizeEntry(entry.id, 'completed', results);
|
|
366
378
|
return { success: true, results };
|
|
367
379
|
}
|
package/src/routes/ui.js
CHANGED
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
listAccounts, getSetting, setSetting, deleteSetting,
|
|
4
4
|
setAdminPassword, verifyAdminPassword, hasAdminPassword,
|
|
5
5
|
listQueueEntries, getQueueEntry, updateQueueStatus, clearQueueByStatus, deleteQueueEntry, getPendingQueueCount, getQueueCounts,
|
|
6
|
-
listApiKeys, createApiKey, deleteApiKey
|
|
6
|
+
listApiKeys, createApiKey, deleteApiKey,
|
|
7
|
+
listUnnotifiedEntries
|
|
7
8
|
} from '../lib/db.js';
|
|
8
9
|
import { connectHsync, disconnectHsync, getHsyncUrl, isHsyncConnected } from '../lib/hsyncManager.js';
|
|
9
10
|
import { executeQueueEntry } from '../lib/queueExecutor.js';
|
|
11
|
+
import { notifyClawdbot, retryNotification } from '../lib/notifier.js';
|
|
10
12
|
import { registerAllRoutes, renderAllCards } from './ui/index.js';
|
|
11
13
|
|
|
12
14
|
const router = Router();
|
|
@@ -121,8 +123,9 @@ router.get('/', (req, res) => {
|
|
|
121
123
|
const hsyncUrl = getHsyncUrl();
|
|
122
124
|
const hsyncConnected = isHsyncConnected();
|
|
123
125
|
const pendingQueueCount = getPendingQueueCount();
|
|
126
|
+
const notificationsConfig = getSetting('notifications');
|
|
124
127
|
|
|
125
|
-
res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }));
|
|
128
|
+
res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }));
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
// Register all service routes (github, bluesky, reddit, etc.)
|
|
@@ -149,6 +152,79 @@ router.post('/hsync/delete', async (req, res) => {
|
|
|
149
152
|
res.redirect('/ui');
|
|
150
153
|
});
|
|
151
154
|
|
|
155
|
+
// Notification settings
|
|
156
|
+
router.post('/notifications/setup', (req, res) => {
|
|
157
|
+
const { url, token, events } = req.body;
|
|
158
|
+
if (!url) {
|
|
159
|
+
return res.status(400).send('Webhook URL required');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Parse events - could be comma-separated string or array
|
|
163
|
+
let eventList = ['completed', 'failed'];
|
|
164
|
+
if (events) {
|
|
165
|
+
eventList = Array.isArray(events) ? events : events.split(',').map(e => e.trim());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setSetting('notifications', {
|
|
169
|
+
clawdbot: {
|
|
170
|
+
enabled: true,
|
|
171
|
+
url: url.replace(/\/$/, ''),
|
|
172
|
+
token: token || '',
|
|
173
|
+
events: eventList,
|
|
174
|
+
retryAttempts: 3,
|
|
175
|
+
retryDelayMs: 5000
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
res.redirect('/ui');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
router.post('/notifications/delete', (req, res) => {
|
|
182
|
+
deleteSetting('notifications');
|
|
183
|
+
res.redirect('/ui');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
router.post('/notifications/test', async (req, res) => {
|
|
187
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
188
|
+
const config = getSetting('notifications');
|
|
189
|
+
|
|
190
|
+
if (!config?.clawdbot?.enabled || !config.clawdbot.url || !config.clawdbot.token) {
|
|
191
|
+
const error = 'Notifications not configured';
|
|
192
|
+
return wantsJson
|
|
193
|
+
? res.status(400).json({ success: false, error })
|
|
194
|
+
: res.status(400).send(error);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(config.clawdbot.url, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: {
|
|
201
|
+
'Authorization': `Bearer ${config.clawdbot.token}`,
|
|
202
|
+
'Content-Type': 'application/json'
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
text: '🧪 [agentgate] Test notification - webhook is working!',
|
|
206
|
+
mode: 'now'
|
|
207
|
+
})
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (response.ok) {
|
|
211
|
+
return wantsJson
|
|
212
|
+
? res.json({ success: true })
|
|
213
|
+
: res.redirect('/ui?notification_test=success');
|
|
214
|
+
} else {
|
|
215
|
+
const text = await response.text().catch(() => '');
|
|
216
|
+
const error = `HTTP ${response.status}: ${text.substring(0, 100)}`;
|
|
217
|
+
return wantsJson
|
|
218
|
+
? res.status(400).json({ success: false, error })
|
|
219
|
+
: res.redirect('/ui?notification_test=failed');
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return wantsJson
|
|
223
|
+
? res.status(500).json({ success: false, error: err.message })
|
|
224
|
+
: res.redirect('/ui?notification_test=failed');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
152
228
|
// Write Queue Management
|
|
153
229
|
router.get('/queue', (req, res) => {
|
|
154
230
|
const filter = req.query.filter || 'all';
|
|
@@ -159,7 +235,8 @@ router.get('/queue', (req, res) => {
|
|
|
159
235
|
entries = listQueueEntries(filter);
|
|
160
236
|
}
|
|
161
237
|
const counts = getQueueCounts();
|
|
162
|
-
|
|
238
|
+
const unnotified = listUnnotifiedEntries();
|
|
239
|
+
res.send(renderQueuePage(entries, filter, counts, unnotified.length));
|
|
163
240
|
});
|
|
164
241
|
|
|
165
242
|
router.post('/queue/:id/approve', async (req, res) => {
|
|
@@ -196,7 +273,7 @@ router.post('/queue/:id/approve', async (req, res) => {
|
|
|
196
273
|
res.redirect('/ui/queue');
|
|
197
274
|
});
|
|
198
275
|
|
|
199
|
-
router.post('/queue/:id/reject', (req, res) => {
|
|
276
|
+
router.post('/queue/:id/reject', async (req, res) => {
|
|
200
277
|
const { id } = req.params;
|
|
201
278
|
const { reason } = req.body;
|
|
202
279
|
const wantsJson = req.headers.accept?.includes('application/json');
|
|
@@ -216,7 +293,12 @@ router.post('/queue/:id/reject', (req, res) => {
|
|
|
216
293
|
|
|
217
294
|
updateQueueStatus(id, 'rejected', { rejection_reason: reason || 'No reason provided' });
|
|
218
295
|
|
|
296
|
+
// Send notification to Clawdbot
|
|
219
297
|
const updated = getQueueEntry(id);
|
|
298
|
+
notifyClawdbot(updated).catch(err => {
|
|
299
|
+
console.error('[notifier] Failed to notify Clawdbot:', err.message);
|
|
300
|
+
});
|
|
301
|
+
|
|
220
302
|
const counts = getQueueCounts();
|
|
221
303
|
|
|
222
304
|
if (wantsJson) {
|
|
@@ -266,6 +348,41 @@ router.delete('/queue/:id', (req, res) => {
|
|
|
266
348
|
res.redirect('/ui/queue');
|
|
267
349
|
});
|
|
268
350
|
|
|
351
|
+
// Retry notification for a specific queue entry
|
|
352
|
+
router.post('/queue/:id/notify', async (req, res) => {
|
|
353
|
+
const { id } = req.params;
|
|
354
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
355
|
+
|
|
356
|
+
const result = await retryNotification(id, getQueueEntry);
|
|
357
|
+
const updated = getQueueEntry(id);
|
|
358
|
+
|
|
359
|
+
if (wantsJson) {
|
|
360
|
+
return res.json({ success: result.success, error: result.error, entry: updated });
|
|
361
|
+
}
|
|
362
|
+
res.redirect('/ui/queue');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Retry all failed notifications
|
|
366
|
+
router.post('/queue/notify-all', async (req, res) => {
|
|
367
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
368
|
+
const unnotified = listUnnotifiedEntries();
|
|
369
|
+
|
|
370
|
+
if (unnotified.length === 0) {
|
|
371
|
+
return wantsJson
|
|
372
|
+
? res.json({ success: true, count: 0 })
|
|
373
|
+
: res.redirect('/ui/queue');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Batch into single notification
|
|
377
|
+
const { notifyClawdbotBatch } = await import('../lib/notifier.js');
|
|
378
|
+
const result = await notifyClawdbotBatch(unnotified);
|
|
379
|
+
|
|
380
|
+
if (wantsJson) {
|
|
381
|
+
return res.json({ success: result.success, error: result.error, count: unnotified.length });
|
|
382
|
+
}
|
|
383
|
+
res.redirect('/ui/queue');
|
|
384
|
+
});
|
|
385
|
+
|
|
269
386
|
// API Keys Management
|
|
270
387
|
router.get('/keys', (req, res) => {
|
|
271
388
|
const keys = listApiKeys();
|
|
@@ -320,7 +437,8 @@ router.delete('/keys/:id', (req, res) => {
|
|
|
320
437
|
|
|
321
438
|
// HTML Templates
|
|
322
439
|
|
|
323
|
-
function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }) {
|
|
440
|
+
function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }) {
|
|
441
|
+
const clawdbotConfig = notificationsConfig?.clawdbot;
|
|
324
442
|
return `<!DOCTYPE html>
|
|
325
443
|
<html>
|
|
326
444
|
<head>
|
|
@@ -402,6 +520,64 @@ curl -X POST http://localhost:${PORT}/api/queue/github/personal/submit \\
|
|
|
402
520
|
</div>
|
|
403
521
|
</details>
|
|
404
522
|
</div>
|
|
523
|
+
|
|
524
|
+
<div class="card">
|
|
525
|
+
<details ${clawdbotConfig?.enabled ? 'open' : ''}>
|
|
526
|
+
<summary>Clawdbot Notifications ${clawdbotConfig?.enabled ? '<span class="status configured">Configured</span>' : ''}</summary>
|
|
527
|
+
<div style="margin-top: 16px;">
|
|
528
|
+
<div id="notification-feedback"></div>
|
|
529
|
+
${clawdbotConfig?.enabled ? `
|
|
530
|
+
<p>Webhook URL: <strong>${clawdbotConfig.url}</strong></p>
|
|
531
|
+
<p>Events: <code>${(clawdbotConfig.events || ['completed', 'failed']).join(', ')}</code></p>
|
|
532
|
+
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
|
533
|
+
<button type="button" class="btn-primary btn-sm" id="test-notification-btn" onclick="testNotification()">Send Test</button>
|
|
534
|
+
<form method="POST" action="/ui/notifications/delete" style="margin: 0;">
|
|
535
|
+
<button type="submit" class="btn-danger btn-sm">Disable</button>
|
|
536
|
+
</form>
|
|
537
|
+
</div>
|
|
538
|
+
<script>
|
|
539
|
+
async function testNotification() {
|
|
540
|
+
const btn = document.getElementById('test-notification-btn');
|
|
541
|
+
const feedback = document.getElementById('notification-feedback');
|
|
542
|
+
btn.disabled = true;
|
|
543
|
+
btn.textContent = 'Sending...';
|
|
544
|
+
feedback.innerHTML = '';
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const res = await fetch('/ui/notifications/test', {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
headers: { 'Accept': 'application/json' }
|
|
550
|
+
});
|
|
551
|
+
const data = await res.json();
|
|
552
|
+
|
|
553
|
+
if (data.success) {
|
|
554
|
+
feedback.innerHTML = '<div class="success-message" style="margin-bottom: 16px;">✓ Test notification sent!</div>';
|
|
555
|
+
} else {
|
|
556
|
+
feedback.innerHTML = '<div class="error-message" style="margin-bottom: 16px;">✗ ' + (data.error || 'Failed to send') + '</div>';
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
feedback.innerHTML = '<div class="error-message" style="margin-bottom: 16px;">✗ ' + err.message + '</div>';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
btn.disabled = false;
|
|
563
|
+
btn.textContent = 'Send Test';
|
|
564
|
+
}
|
|
565
|
+
</script>
|
|
566
|
+
` : `
|
|
567
|
+
<p class="help">Send notifications to <a href="https://docs.clawd.bot" target="_blank">Clawdbot</a> when queue items are completed, failed, or rejected.</p>
|
|
568
|
+
<form method="POST" action="/ui/notifications/setup">
|
|
569
|
+
<label>Webhook URL</label>
|
|
570
|
+
<input type="text" name="url" placeholder="https://your-gateway.example.com/hooks/wake" required>
|
|
571
|
+
<label>Token</label>
|
|
572
|
+
<input type="password" name="token" placeholder="Clawdbot hooks token" required>
|
|
573
|
+
<label>Events (comma-separated)</label>
|
|
574
|
+
<input type="text" name="events" placeholder="completed, failed, rejected" value="completed, failed, rejected">
|
|
575
|
+
<button type="submit" class="btn-primary">Enable Notifications</button>
|
|
576
|
+
</form>
|
|
577
|
+
`}
|
|
578
|
+
</div>
|
|
579
|
+
</details>
|
|
580
|
+
</div>
|
|
405
581
|
</body>
|
|
406
582
|
</html>`;
|
|
407
583
|
}
|
|
@@ -455,7 +631,7 @@ function renderSetupPasswordPage(error = '') {
|
|
|
455
631
|
</html>`;
|
|
456
632
|
}
|
|
457
633
|
|
|
458
|
-
function renderQueuePage(entries, filter, counts) {
|
|
634
|
+
function renderQueuePage(entries, filter, counts, unnotifiedCount = 0) {
|
|
459
635
|
const escapeHtml = (str) => {
|
|
460
636
|
if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
|
|
461
637
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -525,8 +701,28 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
525
701
|
`;
|
|
526
702
|
}
|
|
527
703
|
|
|
704
|
+
// Notification status (only show for completed/failed/rejected)
|
|
705
|
+
let notificationSection = '';
|
|
706
|
+
if (['completed', 'failed', 'rejected'].includes(entry.status)) {
|
|
707
|
+
const notifyStatus = entry.notified
|
|
708
|
+
? `<span class="notify-status notify-sent" title="Notified at ${formatDate(entry.notified_at)}">✓ Notified</span>`
|
|
709
|
+
: entry.notify_error
|
|
710
|
+
? `<span class="notify-status notify-failed" title="${escapeHtml(entry.notify_error)}">⚠ Notify failed</span>`
|
|
711
|
+
: '<span class="notify-status notify-pending">— Not notified</span>';
|
|
712
|
+
|
|
713
|
+
const retryBtn = !entry.notified
|
|
714
|
+
? `<button type="button" class="btn-sm btn-link" onclick="retryNotify('${entry.id}')" id="retry-${entry.id}">Retry</button>`
|
|
715
|
+
: '';
|
|
716
|
+
|
|
717
|
+
notificationSection = `
|
|
718
|
+
<div class="notification-status" id="notify-status-${entry.id}">
|
|
719
|
+
${notifyStatus} ${retryBtn}
|
|
720
|
+
</div>
|
|
721
|
+
`;
|
|
722
|
+
}
|
|
723
|
+
|
|
528
724
|
return `
|
|
529
|
-
<div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}">
|
|
725
|
+
<div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}" data-notified="${entry.notified ? '1' : '0'}">
|
|
530
726
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
|
531
727
|
<div class="entry-header">
|
|
532
728
|
<strong>${entry.service}</strong> / ${entry.account_name}
|
|
@@ -552,6 +748,7 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
552
748
|
</details>
|
|
553
749
|
|
|
554
750
|
${resultSection}
|
|
751
|
+
${notificationSection}
|
|
555
752
|
${actions}
|
|
556
753
|
</div>
|
|
557
754
|
`;
|
|
@@ -704,6 +901,46 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
704
901
|
padding: 60px 40px;
|
|
705
902
|
}
|
|
706
903
|
.empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
|
|
904
|
+
.notification-status {
|
|
905
|
+
margin-top: 12px;
|
|
906
|
+
padding-top: 12px;
|
|
907
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
908
|
+
display: flex;
|
|
909
|
+
align-items: center;
|
|
910
|
+
gap: 12px;
|
|
911
|
+
font-size: 13px;
|
|
912
|
+
}
|
|
913
|
+
.notify-status {
|
|
914
|
+
padding: 4px 10px;
|
|
915
|
+
border-radius: 6px;
|
|
916
|
+
font-weight: 500;
|
|
917
|
+
}
|
|
918
|
+
.notify-sent {
|
|
919
|
+
background: rgba(16, 185, 129, 0.15);
|
|
920
|
+
color: #34d399;
|
|
921
|
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
922
|
+
}
|
|
923
|
+
.notify-failed {
|
|
924
|
+
background: rgba(245, 158, 11, 0.15);
|
|
925
|
+
color: #fbbf24;
|
|
926
|
+
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
927
|
+
}
|
|
928
|
+
.notify-pending {
|
|
929
|
+
background: rgba(156, 163, 175, 0.15);
|
|
930
|
+
color: #9ca3af;
|
|
931
|
+
border: 1px solid rgba(156, 163, 175, 0.3);
|
|
932
|
+
}
|
|
933
|
+
.btn-link {
|
|
934
|
+
background: none;
|
|
935
|
+
border: none;
|
|
936
|
+
color: #818cf8;
|
|
937
|
+
cursor: pointer;
|
|
938
|
+
text-decoration: underline;
|
|
939
|
+
padding: 4px 8px;
|
|
940
|
+
font-size: 13px;
|
|
941
|
+
}
|
|
942
|
+
.btn-link:hover { color: #a5b4fc; }
|
|
943
|
+
.btn-link:disabled { color: #6b7280; cursor: not-allowed; text-decoration: none; }
|
|
707
944
|
</style>
|
|
708
945
|
</head>
|
|
709
946
|
<body>
|
|
@@ -716,6 +953,7 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
716
953
|
<div class="filter-bar" id="filter-bar">
|
|
717
954
|
${filterLinks}
|
|
718
955
|
<div class="clear-section">
|
|
956
|
+
${unnotifiedCount > 0 ? `<button type="button" class="btn-sm btn-primary" onclick="retryAllNotifications()" id="retry-all-btn">Retry ${unnotifiedCount} Notification${unnotifiedCount > 1 ? 's' : ''}</button>` : ''}
|
|
719
957
|
${filter === 'completed' && counts.completed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'completed\')">Clear Completed</button>' : ''}
|
|
720
958
|
${filter === 'failed' && counts.failed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'failed\')">Clear Failed</button>' : ''}
|
|
721
959
|
${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
|
|
@@ -894,6 +1132,9 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
894
1132
|
|
|
895
1133
|
// Hide the clear button if nothing left to clear
|
|
896
1134
|
btn.style.display = 'none';
|
|
1135
|
+
|
|
1136
|
+
// Update retry notifications button
|
|
1137
|
+
updateRetryAllButton();
|
|
897
1138
|
}
|
|
898
1139
|
|
|
899
1140
|
btn.disabled = false;
|
|
@@ -930,6 +1171,89 @@ function renderQueuePage(entries, filter, counts) {
|
|
|
930
1171
|
alert('Error: ' + err.message);
|
|
931
1172
|
}
|
|
932
1173
|
}
|
|
1174
|
+
|
|
1175
|
+
function updateRetryAllButton() {
|
|
1176
|
+
// Count remaining unnotified entries in DOM
|
|
1177
|
+
const unnotified = document.querySelectorAll('.queue-entry[data-notified="0"]').length;
|
|
1178
|
+
const btn = document.getElementById('retry-all-btn');
|
|
1179
|
+
|
|
1180
|
+
if (unnotified === 0) {
|
|
1181
|
+
if (btn) btn.remove();
|
|
1182
|
+
} else if (btn) {
|
|
1183
|
+
btn.textContent = 'Retry ' + unnotified + ' Notification' + (unnotified > 1 ? 's' : '');
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function retryAllNotifications() {
|
|
1188
|
+
const btn = document.getElementById('retry-all-btn');
|
|
1189
|
+
if (btn) {
|
|
1190
|
+
btn.disabled = true;
|
|
1191
|
+
btn.textContent = 'Sending...';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
try {
|
|
1195
|
+
const res = await fetch('/ui/queue/notify-all', {
|
|
1196
|
+
method: 'POST',
|
|
1197
|
+
headers: { 'Accept': 'application/json' }
|
|
1198
|
+
});
|
|
1199
|
+
const data = await res.json();
|
|
1200
|
+
|
|
1201
|
+
if (data.success) {
|
|
1202
|
+
// Refresh the page to show updated status
|
|
1203
|
+
window.location.reload();
|
|
1204
|
+
} else {
|
|
1205
|
+
alert(data.error || 'Failed to send notifications');
|
|
1206
|
+
if (btn) {
|
|
1207
|
+
btn.disabled = false;
|
|
1208
|
+
btn.textContent = 'Retry Notifications';
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
alert('Error: ' + err.message);
|
|
1213
|
+
if (btn) {
|
|
1214
|
+
btn.disabled = false;
|
|
1215
|
+
btn.textContent = 'Retry Notifications';
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function retryNotify(id) {
|
|
1221
|
+
const btn = document.getElementById('retry-' + id);
|
|
1222
|
+
if (btn) {
|
|
1223
|
+
btn.disabled = true;
|
|
1224
|
+
btn.textContent = 'Sending...';
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
try {
|
|
1228
|
+
const res = await fetch('/ui/queue/' + id + '/notify', {
|
|
1229
|
+
method: 'POST',
|
|
1230
|
+
headers: { 'Accept': 'application/json' }
|
|
1231
|
+
});
|
|
1232
|
+
const data = await res.json();
|
|
1233
|
+
|
|
1234
|
+
const statusEl = document.getElementById('notify-status-' + id);
|
|
1235
|
+
if (data.success && data.entry?.notified) {
|
|
1236
|
+
// Update to show success
|
|
1237
|
+
if (statusEl) {
|
|
1238
|
+
statusEl.innerHTML = '<span class="notify-status notify-sent">✓ Notified</span>';
|
|
1239
|
+
}
|
|
1240
|
+
const entryEl = document.getElementById('entry-' + id);
|
|
1241
|
+
if (entryEl) entryEl.dataset.notified = '1';
|
|
1242
|
+
} else {
|
|
1243
|
+
// Show error
|
|
1244
|
+
const error = data.error || 'Failed to send';
|
|
1245
|
+
if (statusEl) {
|
|
1246
|
+
statusEl.innerHTML = '<span class="notify-status notify-failed" title="' + escapeHtml(error) + '">⚠ Notify failed</span> <button type="button" class="btn-sm btn-link" onclick="retryNotify(\\''+id+'\\')">Retry</button>';
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
alert('Error: ' + err.message);
|
|
1251
|
+
if (btn) {
|
|
1252
|
+
btn.disabled = false;
|
|
1253
|
+
btn.textContent = 'Retry';
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
933
1257
|
</script>
|
|
934
1258
|
</body>
|
|
935
1259
|
</html>`;
|