openvibers/openvibe.media Commits

Show outbound webhook tallies on /me/ops and clear last error on success

OP OpenVibers committed 2 Oct 2026 · a75784b8bb · 3 files, +24 −2

Asked: PR https://github.com/OpenVibers/OpenVibe.Media/pull/8 ("Add per-app webhook send tallies and an operator webhooks section") is green on CI, but review returned FIX with two findings. Fix exactly these two and nothing else, on branch agent/media-plan-t4-lane-c-media-092552-muqrcxge.
Harness job task-Media-pr-https-github-com-openvibers-openvibe--110538.

Changes

ddiff --git a/server/me/pages.js b/server/me/pages.js
iindex c38c89f..2d1e439 100644
---- a/server/me/pages.js
++++ b/server/me/pages.js
@@@ -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 = {
ddiff --git a/server/webhooks.js b/server/webhooks.js
iindex 6009194..c6aebd4 100644
---- a/server/webhooks.js
++++ b/server/webhooks.js
@@@ -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) {
ddiff --git a/test/me-explorer.test.js b/test/me-explorer.test.js
iindex 2605089..4f91b89 100644
---- a/test/me-explorer.test.js
++++ b/test/me-explorer.test.js
@@@ -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'");