check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5
202 lines
7.1 KiB
JavaScript
202 lines
7.1 KiB
JavaScript
// Webhooker client-side JavaScript
|
|
|
|
// Copy-to-clipboard, as progressive enhancement.
|
|
//
|
|
// Markup renders each copy button with the `hidden` attribute and a
|
|
// `data-copy-target` pointing at the id of the element holding the
|
|
// text. This script reveals a button only once it has both a resolvable
|
|
// target and a usable Clipboard API, so a browser without either shows
|
|
// no button at all and the text stays selectable.
|
|
(function () {
|
|
"use strict";
|
|
|
|
const revertDelayMs = 2000;
|
|
|
|
function flash(button, message) {
|
|
const original = button.getAttribute("data-copy-label");
|
|
button.textContent = message;
|
|
window.setTimeout(function () {
|
|
button.textContent = original;
|
|
}, revertDelayMs);
|
|
}
|
|
|
|
function wire(button) {
|
|
const target = document.getElementById(
|
|
button.getAttribute("data-copy-target")
|
|
);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
button.setAttribute("data-copy-label", button.textContent);
|
|
button.addEventListener("click", function () {
|
|
navigator.clipboard.writeText(target.textContent.trim()).then(
|
|
function () {
|
|
flash(button, "Copied");
|
|
},
|
|
function () {
|
|
flash(button, "Copy failed");
|
|
}
|
|
);
|
|
});
|
|
button.removeAttribute("hidden");
|
|
}
|
|
|
|
function init() {
|
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
return;
|
|
}
|
|
|
|
const buttons = document.querySelectorAll("[data-copy-target]");
|
|
buttons.forEach(wire);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
|
|
// Alpine.js components.
|
|
//
|
|
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
|
// Alpine's CSP build, which cannot run expressions written in the
|
|
// markup: a directive in templates/ may only name a property or method,
|
|
// and each x-data names a component registered here. This script runs
|
|
// before Alpine, whose script tag is deferred, so this listener is in
|
|
// place when Alpine starts.
|
|
document.addEventListener("alpine:init", function () {
|
|
"use strict";
|
|
|
|
// Something a click shows and hides: the mobile menu, an add form,
|
|
// an entrypoint's edit form, an event in the event log or in the
|
|
// recent events, a delivery's attempts, the new webhook page's
|
|
// archive pruning and rotation choices. It starts hidden, or shown
|
|
// when its element has the data-open attribute.
|
|
window.Alpine.data("collapsible", function () {
|
|
return {
|
|
open: false,
|
|
// The timer of the toggle a single click is waiting to make.
|
|
pendingToggle: null,
|
|
init() {
|
|
this.open = this.$root.hasAttribute("data-open");
|
|
},
|
|
toggle() {
|
|
this.open = !this.open;
|
|
},
|
|
// Toggles on a click, except one that selects text, such as
|
|
// selecting an event's ID to copy it. A single click toggles
|
|
// only after 500 ms, the usual double-click interval, and the
|
|
// second press of a double- or triple-click cancels that (see
|
|
// cancelPendingToggle), so nothing moves under the pointer
|
|
// while it selects text.
|
|
toggleUnlessSelecting(event) {
|
|
if (
|
|
event.detail === 1 &&
|
|
window.getSelection().toString() === ""
|
|
) {
|
|
this.pendingToggle = setTimeout(() => this.toggle(), 500);
|
|
}
|
|
},
|
|
// Runs when the mouse button goes down, so the second press
|
|
// of a double- or triple-click cancels the toggle its first
|
|
// click is waiting to make, however long that press lasts.
|
|
cancelPendingToggle(event) {
|
|
if (event.detail > 1) {
|
|
clearTimeout(this.pendingToggle);
|
|
}
|
|
},
|
|
get closed() {
|
|
return !this.open;
|
|
},
|
|
// Turns a downward caret up while open.
|
|
get caretClass() {
|
|
return { "rotate-180": this.open };
|
|
},
|
|
};
|
|
});
|
|
|
|
// The targets section's add target form, in three steps: closed,
|
|
// choosing a type, then filling in that type's fields. targetType
|
|
// is empty until Next takes it from the type select.
|
|
//
|
|
// The reason and the fields' values come from the properties below
|
|
// rather than from the markup, because each type's fields are made
|
|
// afresh from the markup whenever that type is chosen. A refused
|
|
// submission comes back with its type, reason and values in the
|
|
// section's data attributes, and starts on that type's fields with
|
|
// them. Cancel empties these properties and resets the form, which
|
|
// holds whatever was typed, so the next Add starts with an empty
|
|
// form and no reason.
|
|
window.Alpine.data("targetForm", function () {
|
|
return {
|
|
choosing: false,
|
|
targetType: "",
|
|
reason: "",
|
|
name: "",
|
|
url: "",
|
|
headers: "",
|
|
timeout: "",
|
|
forwardQuery: false,
|
|
maxAttempts: "",
|
|
expiry: "",
|
|
rotation: "",
|
|
init() {
|
|
const refused = this.$root.dataset;
|
|
|
|
this.targetType = refused.type;
|
|
this.reason = refused.reason;
|
|
this.name = refused.name;
|
|
this.url = refused.destination;
|
|
this.headers = refused.headers;
|
|
this.timeout = refused.timeout;
|
|
this.forwardQuery =
|
|
this.$root.hasAttribute("data-forward-query");
|
|
this.maxAttempts = refused.maxAttempts;
|
|
this.expiry = refused.expiry;
|
|
this.rotation = refused.rotation;
|
|
},
|
|
add() {
|
|
this.choosing = true;
|
|
},
|
|
next() {
|
|
this.targetType = this.$refs.type.value;
|
|
this.choosing = false;
|
|
},
|
|
cancel() {
|
|
this.choosing = false;
|
|
this.targetType = "";
|
|
this.reason = "";
|
|
this.name = "";
|
|
this.url = "";
|
|
this.headers = "";
|
|
this.timeout = "";
|
|
this.forwardQuery = false;
|
|
this.maxAttempts = "";
|
|
this.expiry = "";
|
|
this.rotation = "";
|
|
this.$refs.form.reset();
|
|
},
|
|
get filling() {
|
|
return this.targetType !== "";
|
|
},
|
|
get closed() {
|
|
return !this.choosing && !this.filling;
|
|
},
|
|
get isHttp() {
|
|
return this.targetType === "http";
|
|
},
|
|
get isSlack() {
|
|
return this.targetType === "slack";
|
|
},
|
|
get isDatabase() {
|
|
return this.targetType === "database";
|
|
},
|
|
get isLog() {
|
|
return this.targetType === "log";
|
|
},
|
|
};
|
|
});
|
|
});
|