check / check (push) Waiting to run
Each entrypoint on the webhook page has an Edit button that shows its
description as a form, with Save and Cancel, in place. Edit hides while
the form is open, and going back to the page does not put unsaved text
back into it, so the form always opens on the saved description.
Saving posts to /hook/{id}/entrypoints/{entrypointID}/edit, behind the
same login, CSRF and ownership checks as activate, deactivate and
delete, and writes only the description column, so the URL never
changes. An empty description shows as "Entrypoint". Activate and
deactivate now write only the active column, so they cannot write back
an older description over an edit.
Model: opus-5-5
245 lines
15 KiB
HTML
245 lines
15 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>
|
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
|
<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 this entrypoint?')" 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>
|
|
{{else}}
|
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Targets -->
|
|
<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">Targets</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 target 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}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<div class="flex gap-2">
|
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
|
<select name="type" @change="readType" class="input text-sm w-32">
|
|
<option value="http">HTTP</option>
|
|
<option value="slack">Slack</option>
|
|
<option value="database">Database</option>
|
|
<option value="log">Log</option>
|
|
</select>
|
|
</div>
|
|
<div x-show="isHttp">
|
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
|
</div>
|
|
<div x-show="isHttp">
|
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" 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 x-show="isHttp" class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
|
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
|
</div>
|
|
<div x-show="isHttp">
|
|
<div class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Max retries:</label>
|
|
<input type="number" name="max_retries" value="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 x-show="isSlack">
|
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" 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 x-show="isDatabase">
|
|
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
|
</div>
|
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
|
</form>
|
|
</div>
|
|
|
|
<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 this target?')" 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>
|
|
{{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}}
|
|
</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">
|
|
{{range .Events}}
|
|
<div class="p-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div 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}}
|
|
</div>
|
|
<div 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>
|
|
</div>
|
|
</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}}
|