check / check (push) Successful in 3m21s
The webhook, entrypoint and target delete prompts on the webhook page now name the item and say what deleting it loses: the webhook's stored events (with the count from the statistics pane) and their deliveries, while its archive files are kept; an entrypoint's URL, which stops working for good; a target's future deliveries, while its past ones stay in the event log. They stay the browser's own prompts, so they work without the page's scripts, and each name is escaped for the script so quotes and backslashes show as typed. Model: opus-5-5
363 lines
23 KiB
HTML
363 lines
23 KiB
HTML
{{template "base" .}}
|
|
|
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
|
|
|
{{define "content"}}
|
|
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
|
event log, the navbar and the footer, so an entrypoint URL fits on
|
|
one line. An inline style, because the committed tailwind.css has
|
|
no class this wide. -->
|
|
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
|
|
<div class="mb-6">
|
|
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
|
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
|
<div>
|
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
|
{{if .Webhook.Description}}
|
|
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
|
|
{{end}}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
|
<!-- The delete prompts are the browser's own, so they
|
|
work without the page's scripts. The template
|
|
escapes each name for the script, so a quote or a
|
|
backslash in it shows as typed. -->
|
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook "{{.Webhook.Name}}"?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<button type="submit" class="btn-danger">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{template "webhook_stats" .}}
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- Entrypoints -->
|
|
<div class="card" x-data="collapsible">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
|
<button type="button" @click="toggle" class="btn-small">
|
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Add entrypoint form -->
|
|
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
|
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="divide-y divide-gray-100">
|
|
{{range .Entrypoints}}
|
|
<div class="p-4" x-data="collapsible">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
|
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
|
<!-- Edit shows this form in place of the
|
|
description and hides until it closes, and
|
|
Cancel resets what was typed. With
|
|
autocomplete="off", going back to the page
|
|
does not put unsaved text back either, so
|
|
the form always opens on the saved
|
|
description. -->
|
|
<form x-show="open" x-cloak method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/edit" class="flex w-full gap-2">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<input type="text" name="description" value="{{.Description}}" autocomplete="off" placeholder="Description (optional)" class="input text-sm flex-1">
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
<button type="reset" @click="toggle" class="btn-secondary text-sm">Cancel</button>
|
|
</form>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{{if .Active}}
|
|
<span class="badge-success">Active</span>
|
|
{{else}}
|
|
<span class="badge-error">Inactive</span>
|
|
{{end}}
|
|
<button type="button" x-show="closed" @click="toggle" class="btn-small" title="Edit">Edit</button>
|
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-2 mt-1">
|
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
|
|
<!-- Hidden until app.js reveals it; without the
|
|
script the URL above stays selectable. -->
|
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="btn-small">Copy</button>
|
|
</div>
|
|
<!-- The URL above is the entrypoint's credential:
|
|
anyone holding it can submit events. -->
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Last Event:</span>
|
|
{{if .LastEvent}}
|
|
<span title="{{.LastEventUTC}}">{{.LastEvent}}</span>
|
|
{{else}}
|
|
<span>never</span>
|
|
{{end}}
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Events Within Retention:</span>
|
|
<span>{{.Events}}</span>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Targets. The data attributes carry a refused add target
|
|
submission's type, reason and values back to the form. The
|
|
URL is data-destination, not data-url: html/template treats
|
|
an attribute named like a URL as a link and would rewrite
|
|
a refused ftp: or javascript: value. -->
|
|
<div class="card" x-data="targetForm"
|
|
data-type="{{.TargetForm.Type}}"
|
|
data-reason="{{.TargetError}}"
|
|
data-name="{{.TargetForm.Name}}"
|
|
data-destination="{{.TargetForm.URL}}"
|
|
data-headers="{{.TargetForm.Headers}}"
|
|
data-timeout="{{.TargetForm.Timeout}}"
|
|
data-max-retries="{{.TargetForm.MaxRetries}}"
|
|
data-expiry="{{.TargetForm.Expiry}}">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
|
<button type="button" @click="add" x-show="closed" class="btn-small">
|
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Add target form. Add shows the type choice; Next replaces
|
|
it with the chosen type's fields. Each type's fields,
|
|
and the hidden type field submitted with them, exist
|
|
only while that type is chosen. A refused submission
|
|
comes back open on its type, with the values entered;
|
|
Cancel empties the form. The type choice's p-2, narrower
|
|
than an input's own padding, keeps it, Next and Cancel on
|
|
one row on a 360px-wide phone. -->
|
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
|
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
|
<option value="http">HTTP</option>
|
|
<option value="slack">Slack</option>
|
|
<option value="database">Database</option>
|
|
<option value="log">Log</option>
|
|
</select>
|
|
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
|
</div>
|
|
<div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
|
|
<div x-show="reason" x-text="reason" class="alert-error"></div>
|
|
<input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
|
|
<template x-if="isHttp">
|
|
<div class="space-y-3">
|
|
<input type="hidden" name="type" value="http">
|
|
<input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
|
|
<div>
|
|
<textarea name="headers" rows="3" :value="headers" placeholder="Authorization: Bearer ..." class="input text-sm"></textarea>
|
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
|
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
|
|
</div>
|
|
<div>
|
|
<div class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Max retries:</label>
|
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="isSlack">
|
|
<div class="space-y-3">
|
|
<input type="hidden" name="type" value="slack">
|
|
<div>
|
|
<input type="url" name="url" :value="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
|
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
|
</div>
|
|
<div>
|
|
<div class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Max retries:</label>
|
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="isDatabase">
|
|
<div>
|
|
<input type="hidden" name="type" value="database">
|
|
<div class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Archive expiry:</label>
|
|
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
|
{{range .ArchiveExpiryChoices}}
|
|
<option value="{{.Value}}">{{.Label}}</option>
|
|
{{end}}
|
|
</select>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
|
</div>
|
|
</template>
|
|
<template x-if="isLog">
|
|
<div>
|
|
<input type="hidden" name="type" value="log">
|
|
<p class="text-xs text-gray-500">A log target writes each event to the application log. It has no settings beyond its name.</p>
|
|
</div>
|
|
</template>
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="divide-y divide-gray-100">
|
|
{{range .Targets}}
|
|
<div class="p-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
|
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="badge-info">{{.Type}}</span>
|
|
{{if .Active}}
|
|
<span class="badge-success">Active</span>
|
|
{{else}}
|
|
<span class="badge-error">Inactive</span>
|
|
{{end}}
|
|
{{if eq .Type "database"}}
|
|
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/download" class="btn-small" title="Download the archive as gzipped JSON">Download</a>
|
|
{{end}}
|
|
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="btn-small" title="Edit">Edit</a>
|
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target "{{.Name}}"?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{{with .Paused}}
|
|
<div class="text-xs text-yellow-600 mt-1">
|
|
<span class="font-medium">Deliveries Paused:</span>
|
|
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{range .Config}}
|
|
<div class="text-xs text-gray-500 break-all mt-1">
|
|
<span class="font-medium text-gray-700">{{.Label}}:</span>
|
|
<span>{{.Value}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{with .Archive}}
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Archive File:</span>
|
|
<span class="break-all">{{.Name}}</span>
|
|
{{with .Note}}<span>({{.}})</span>{{end}}
|
|
</div>
|
|
{{if .Size}}
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Archive Size:</span>
|
|
<span>{{.Size}}</span>
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Last Written:</span>
|
|
<span title="{{.WrittenUTC}}">{{.Written}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
{{with .Deliveries}}
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Delivered:</span>
|
|
<span>{{.Delivered}} in total, {{.DeliveredLast24Hours}} in the last 24 hours</span>
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
<span class="font-medium text-gray-700">Failed:</span>
|
|
<span>{{.Failed}} in total, {{.FailedLast24Hours}} in the last 24 hours</span>
|
|
</div>
|
|
{{else}}
|
|
<div class="text-xs text-gray-500 mt-1">The delivery counts could not be read.</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Events -->
|
|
<div class="card mt-6">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
|
</div>
|
|
<div class="divide-y divide-gray-100">
|
|
<!-- Each row expands to show its body; only the newest
|
|
starts expanded. -->
|
|
{{range $i, $event := .Events}}
|
|
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-3 text-left" @click="toggle">
|
|
<span class="flex flex-wrap items-center gap-3">
|
|
<span class="badge-info">{{.Method}}</span>
|
|
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
|
{{if .ResubmittedFromID}}
|
|
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
|
{{end}}
|
|
</span>
|
|
<span class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
|
<span title="Body size">{{.Size}}</span>
|
|
{{if .ProcessingTime}}
|
|
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
|
{{end}}
|
|
{{if .Status}}
|
|
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
|
{{end}}
|
|
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<a href="{{.Body.EventURL}}" class="btn-small">Open</a>
|
|
</div>
|
|
<div x-show="open" x-cloak class="mt-3">
|
|
{{template "event_body" .Body}}
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<div class="p-8 text-center text-sm text-gray-500">No events received yet.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="mt-4 text-sm text-gray-400">
|
|
<p>Retention: {{.Webhook.RetentionLabel}} · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
|
|
</div>
|
|
</div>
|
|
{{end}}
|