openvibers/openvibe.media Commits
Show outbound webhook tallies on /me/ops and clear last error on success
Changes
@@@ -430,8 +430,18 @@ function renderOps({ person, report, canRecompute, recomputed = null }) {esc(fmtWhen(d.decided_at)), esc(d.app_id || ''), `<code class="id">${esc(d.object_id)}</code>`, esc(d.action), esc(d.outcome.replace('_', ' ')), esc(d.error || d.reason || ''),])) || '<p class="empty">None.</p>'}` : ''}</section>++++ <section aria-labelledby="webhooks-h">++ <h2 id="webhooks-h">Outbound webhooks</h2>++ <p class="hint">${esc(r.webhooks.note)}</p>++ <p class="hint">Sends counted since ${esc(fmtWhen(r.webhooks.sends.since))} in this process.</p>++ ${table('Apps configured to receive webhooks, host only', ['Tenant', 'Host', 'Sent', 'Failed', 'Last success', 'Last error'], r.webhooks.configured.map(c => {++ const s = r.webhooks.sends.by_app[c.app_id] || {};++ return [esc(c.app_id), esc(c.host || 'unparseable URL'), num(s.sent || 0), num(s.failed || 0), esc(fmtWhen(s.last_ok_at) || 'never'), s.last_error ? `<pre class="err">${esc(s.last_error)}</pre>` : ''];++ }), { numeric: [2, 3] }) || '<p class="empty">No app is configured to receive webhooks.</p>'}++ </section><p class="hint">JSON: <a href="/api/v2/me/ops${scopeQs}">/api/v2/me/ops</a>.</p>`;-- return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill and tiering.', body });++ return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill, tiering and webhooks.', body });}module.exports = {@@@ -96,6 +96,7 @@ async function sendWebhook(appOrId, event, data, { eventId = null } = {}) {await _post(app.webhook_url, rawBody, signature);t.sent++;t.last_ok_at = new Date().toISOString();++ t.last_error = null;return true;} catch (err) {if (attempt === MAX_ATTEMPTS) {@@@ -350,6 +350,8 @@ const http = require('http');assert.deepStrictEqual(r.json.webhooks.configured, [], 'tools has no webhook');r = await call('GET', '/me/ops', { cookie: 'admin' });assert.ok(r.status === 200 && r.text.includes('mjob_TESTFAILED') && r.text.includes('action="/me/ops/recompute"') && r.text.includes('noindex'));++ assert.ok(r.text.includes('webhooks-h') && r.text.includes('hooks.example.test'), 'the page shows the webhooks section and the configured host');++ assert.ok(!r.text.includes(HOOK_URL) && !r.text.includes('ov-private-hook-path') && !r.text.includes(HOOK_SECRET), 'the page never shows the webhook URL path or secret');console.log('✅ operator views: staff.site.view only (not users, not global mods); failed jobs, missing media, backfill, tiering, namespaces');// ── Recompute: staff.site.configure, same-origin ──@@@ -391,14 +393,23 @@ const http = require('http');await db.run("UPDATE apps SET webhook_url = 'https://hooks-down.example.test/never' WHERE app_id = 'tools'");const realFetch = global.fetch;hooks._setRetryBaseMs(0); // retries must not slow the suite (never a global setTimeout patch: the pg pool's timers use it)++ let down = true;global.fetch = async (url, opts) => {const u = String(url);if (!u.includes('hooks.example.test') && !u.includes('hooks-down.example.test')) return realFetch(url, opts);-- return { ok: u.includes('hooks.example.test'), status: u.includes('hooks.example.test') ? 200 : 500 };++ const ok = u.includes('hooks.example.test') || !down;++ return { ok, status: ok ? 200 : 500 };};try {assert.strictEqual(await hooks.sendWebhook('live', 'vod.ready', { id: 1 }), true);assert.strictEqual(await hooks.sendWebhook('tools', 'clip.ready', { id: 2 }), false);++ // The same app answers 200 on a later send: the recorded error must be cleared.++ const ghost = { app_id: 'ghost', webhook_url: 'https://hooks-down.example.test/x', webhook_secret: 's' };++ assert.strictEqual(await hooks.sendWebhook(ghost, 'clip.ready', { id: 3 }), false);++ assert.strictEqual(hooks.stats().by_app.ghost.last_error, 'HTTP 500');++ down = false;++ assert.strictEqual(await hooks.sendWebhook(ghost, 'clip.ready', { id: 4 }), true);++ assert.strictEqual(hooks.stats().by_app.ghost.last_error, null, 'a later success clears the previous error');} finally {global.fetch = realFetch; hooks._setRetryBaseMs(null);await db.run("UPDATE apps SET webhook_url = NULL, webhook_secret = NULL WHERE app_id = 'tools'");
@@ -430,8 +430,18 @@ function renderOps({ person, report, canRecompute, recomputed = null }) {
@@ -430,8 +430,18 @@ function renderOps({ person, report, canRecompute, recomputed = null }) {
esc(fmtWhen(d.decided_at)), esc(d.app_id || ''), `<code class="id">${esc(d.object_id)}</code>`, esc(d.action), esc(d.outcome.replace('_', ' ')), esc(d.error || d.reason || ''),
esc(fmtWhen(d.decided_at)), esc(d.app_id || ''), `<code class="id">${esc(d.object_id)}</code>`, esc(d.action), esc(d.outcome.replace('_', ' ')), esc(d.error || d.reason || ''),
])) || '<p class="empty">None.</p>'}` : ''}
])) || '<p class="empty">None.</p>'}` : ''}
</section>
</section>
+
+ <section aria-labelledby="webhooks-h">
+ <h2 id="webhooks-h">Outbound webhooks</h2>
+ <p class="hint">${esc(r.webhooks.note)}</p>
+ <p class="hint">Sends counted since ${esc(fmtWhen(r.webhooks.sends.since))} in this process.</p>
+ ${table('Apps configured to receive webhooks, host only', ['Tenant', 'Host', 'Sent', 'Failed', 'Last success', 'Last error'], r.webhooks.configured.map(c => {
+ const s = r.webhooks.sends.by_app[c.app_id] || {};
+ return [esc(c.app_id), esc(c.host || 'unparseable URL'), num(s.sent || 0), num(s.failed || 0), esc(fmtWhen(s.last_ok_at) || 'never'), s.last_error ? `<pre class="err">${esc(s.last_error)}</pre>` : ''];
+ }), { numeric: [2, 3] }) || '<p class="empty">No app is configured to receive webhooks.</p>'}
+ </section>
<p class="hint">JSON: <a href="/api/v2/me/ops${scopeQs}">/api/v2/me/ops</a>.</p>`;
<p class="hint">JSON: <a href="/api/v2/me/ops${scopeQs}">/api/v2/me/ops</a>.</p>`;
- return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill and tiering.', body });
+ return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill, tiering and webhooks.', body });
}
}
module.exports = {
module.exports = {
@@ -96,6 +96,7 @@ async function sendWebhook(appOrId, event, data, { eventId = null } = {}) {
@@ -96,6 +96,7 @@ async function sendWebhook(appOrId, event, data, { eventId = null } = {}) {
await _post(app.webhook_url, rawBody, signature);
await _post(app.webhook_url, rawBody, signature);
t.sent++;
t.sent++;
t.last_ok_at = new Date().toISOString();
t.last_ok_at = new Date().toISOString();
+ t.last_error = null;
return true;
return true;
} catch (err) {
} catch (err) {
if (attempt === MAX_ATTEMPTS) {
if (attempt === MAX_ATTEMPTS) {
@@ -350,6 +350,8 @@ const http = require('http');
@@ -350,6 +350,8 @@ const http = require('http');
assert.deepStrictEqual(r.json.webhooks.configured, [], 'tools has no webhook');
assert.deepStrictEqual(r.json.webhooks.configured, [], 'tools has no webhook');
r = await call('GET', '/me/ops', { cookie: 'admin' });
r = await call('GET', '/me/ops', { cookie: 'admin' });
assert.ok(r.status === 200 && r.text.includes('mjob_TESTFAILED') && r.text.includes('action="/me/ops/recompute"') && r.text.includes('noindex'));
assert.ok(r.status === 200 && r.text.includes('mjob_TESTFAILED') && r.text.includes('action="/me/ops/recompute"') && r.text.includes('noindex'));
+ assert.ok(r.text.includes('webhooks-h') && r.text.includes('hooks.example.test'), 'the page shows the webhooks section and the configured host');
+ assert.ok(!r.text.includes(HOOK_URL) && !r.text.includes('ov-private-hook-path') && !r.text.includes(HOOK_SECRET), 'the page never shows the webhook URL path or secret');
console.log('✅ operator views: staff.site.view only (not users, not global mods); failed jobs, missing media, backfill, tiering, namespaces');
console.log('✅ operator views: staff.site.view only (not users, not global mods); failed jobs, missing media, backfill, tiering, namespaces');
// ── Recompute: staff.site.configure, same-origin ──
// ── Recompute: staff.site.configure, same-origin ──
@@ -391,14 +393,23 @@ const http = require('http');
@@ -391,14 +393,23 @@ const http = require('http');
await db.run("UPDATE apps SET webhook_url = 'https://hooks-down.example.test/never' WHERE app_id = 'tools'");
await db.run("UPDATE apps SET webhook_url = 'https://hooks-down.example.test/never' WHERE app_id = 'tools'");
const realFetch = global.fetch;
const realFetch = global.fetch;
hooks._setRetryBaseMs(0); // retries must not slow the suite (never a global setTimeout patch: the pg pool's timers use it)
hooks._setRetryBaseMs(0); // retries must not slow the suite (never a global setTimeout patch: the pg pool's timers use it)
+ let down = true;
global.fetch = async (url, opts) => {
global.fetch = async (url, opts) => {
const u = String(url);
const u = String(url);
if (!u.includes('hooks.example.test') && !u.includes('hooks-down.example.test')) return realFetch(url, opts);
if (!u.includes('hooks.example.test') && !u.includes('hooks-down.example.test')) return realFetch(url, opts);
- return { ok: u.includes('hooks.example.test'), status: u.includes('hooks.example.test') ? 200 : 500 };
+ const ok = u.includes('hooks.example.test') || !down;
+ return { ok, status: ok ? 200 : 500 };
};
};
try {
try {
assert.strictEqual(await hooks.sendWebhook('live', 'vod.ready', { id: 1 }), true);
assert.strictEqual(await hooks.sendWebhook('live', 'vod.ready', { id: 1 }), true);
assert.strictEqual(await hooks.sendWebhook('tools', 'clip.ready', { id: 2 }), false);
assert.strictEqual(await hooks.sendWebhook('tools', 'clip.ready', { id: 2 }), false);
+ // The same app answers 200 on a later send: the recorded error must be cleared.
+ const ghost = { app_id: 'ghost', webhook_url: 'https://hooks-down.example.test/x', webhook_secret: 's' };
+ assert.strictEqual(await hooks.sendWebhook(ghost, 'clip.ready', { id: 3 }), false);
+ assert.strictEqual(hooks.stats().by_app.ghost.last_error, 'HTTP 500');
+ down = false;
+ assert.strictEqual(await hooks.sendWebhook(ghost, 'clip.ready', { id: 4 }), true);
+ assert.strictEqual(hooks.stats().by_app.ghost.last_error, null, 'a later success clears the previous error');
} finally {
} finally {
global.fetch = realFetch; hooks._setRetryBaseMs(null);
global.fetch = realFetch; hooks._setRetryBaseMs(null);
await db.run("UPDATE apps SET webhook_url = NULL, webhook_secret = NULL WHERE app_id = 'tools'");
await db.run("UPDATE apps SET webhook_url = NULL, webhook_secret = NULL WHERE app_id = 'tools'");