check / check (push) Waiting to run
The type select used w-40, which the committed static/css/tailwind.css has no rule for, so it took the full width and pushed Next and Cancel onto the line below. It now uses w-32, as the old type select did, so the type choice, Next and Cancel sit on one row. Model: opus-5-5
295 lines
18 KiB
HTML
295 lines
18 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">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
|
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
|
<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}}
|
|
<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. 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. -->
|
|
<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 w-32">
|
|
<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">
|
|
<input type="text" name="expiry" :value="expiry" placeholder="never" 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>
|
|
</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 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}}
|
|
{{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}}
|
|
</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}}
|