Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
+3
-2
@@ -7,6 +7,7 @@
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<div class="flex-grow">
|
||||
{{template "navbar" .}}
|
||||
{{template "notice" .}}
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
@@ -21,9 +22,9 @@
|
||||
<footer class="bg-gray-100 border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8">
|
||||
<div class="max-w-6xl mx-auto px-8 py-6">
|
||||
<div class="text-center text-sm text-gray-500 font-mono font-light">
|
||||
<a href="https://git.eeqj.de/sneak/webhooker" class="hover:text-gray-700">Webhooker</a>
|
||||
<a href="https://git.eeqj.de/sneak/webhooker" class="btn-small">Webhooker</a>
|
||||
<span class="mx-1">by</span>
|
||||
<a href="https://sneak.berlin" class="hover:text-gray-700">@sneak</a>
|
||||
<a href="https://sneak.berlin" class="btn-small">@sneak</a>
|
||||
<span class="mx-3">|</span>
|
||||
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "delivery_attempts"}}
|
||||
<!-- A delivery's recorded attempts, as handlers.DeliveryView holds
|
||||
them: in the event log and on an event's own page. -->
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
{{range .Results}}
|
||||
{{/* Inside this loop, dot is one attempt and $ the whole delivery. */}}
|
||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
||||
<span class="text-gray-500" title="{{.RanUTC}}">{{.Ran}}</span>
|
||||
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}}</span>
|
||||
{{/* A database or log target sends no HTTP request, so its attempts have no status code. */}}
|
||||
{{if not (eq $.Target.Type "database" "log")}}
|
||||
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
||||
{{end}}
|
||||
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
|
||||
{{end}}
|
||||
{{if .ResponseBody}}
|
||||
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
|
||||
{{end}}
|
||||
{{if .ResponseTruncated}}
|
||||
{{if .ResponseSizeKnown}}
|
||||
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
|
||||
{{else}}
|
||||
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "delivery_row"}}
|
||||
<!-- The line that heads a delivery, as handlers.DeliveryView holds it:
|
||||
in the event log, inside the button that shows its attempts, and on
|
||||
an event's own page. Spans only, since a button may hold no div. -->
|
||||
<span class="flex flex-1 flex-wrap items-center justify-between gap-3">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-gray-700 wrap-anywhere">{{.Target.DisplayName}}</span>
|
||||
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
|
||||
</span>
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
</span>
|
||||
</span>
|
||||
{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
||||
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
||||
{{if .User}}
|
||||
<a href="/hooks" class="btn-secondary">Back to webhooks</a>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Sign in</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "event_body"}}
|
||||
<!-- An event's body, as handlers.BodyView describes it: the same in
|
||||
the recent events on the webhook page, the event log and the
|
||||
event's own page. -->
|
||||
{{if .Binary}}
|
||||
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||
{{else if .Text}}
|
||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all{{if .Scroll}} max-h-[32rem] overflow-y-auto{{end}}">{{.Text}}</pre>
|
||||
{{if .Cut}}
|
||||
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No body.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,90 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Event - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
||||
</div>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Event</h1>
|
||||
</div>
|
||||
|
||||
{{with .Event}}
|
||||
<div class="card p-4">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">ID</dt>
|
||||
<dd class="font-mono text-gray-900 break-all">{{.ID}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
|
||||
<dd class="text-gray-900">{{.ReceivedUTC}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
|
||||
<dd><span class="badge-info">{{.Method}}</span></dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Content type</dt>
|
||||
<dd class="text-gray-900 break-all">{{.ContentType}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Body size</dt>
|
||||
<dd class="text-gray-900">{{.Body.Size}} bytes</dd>
|
||||
</div>
|
||||
{{if .ResubmittedFrom}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted from</dt>
|
||||
<dd><a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a></dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ResubmitCount}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted</dt>
|
||||
<dd class="text-gray-900">as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Request</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{{template "event_request" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{{template "event_body" .Body}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Deliveries</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Deliveries}}
|
||||
<div class="p-4">
|
||||
{{template "delivery_row" .}}
|
||||
<div class="mt-2 space-y-2">
|
||||
{{template "delivery_attempts" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">No deliveries.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "event_request"}}
|
||||
<!-- The entrypoint an event arrived at, its query string and its
|
||||
request headers, as handlers.EventLogView carries them: the same in
|
||||
the event log and the event's own page. The entrypoint's URL is
|
||||
never shown. A resubmitted copy, even a copy of a copy, did not
|
||||
arrive at an entrypoint; the request it copies did. -->
|
||||
<div class="space-y-2 text-xs">
|
||||
{{if .ResubmittedFrom}}
|
||||
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
|
||||
{{else}}
|
||||
<p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
|
||||
{{end}}
|
||||
{{if .RawQueryCut}}
|
||||
<p class="text-gray-500">The query string is larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the query string</a></p>
|
||||
{{else if .RawQuery}}
|
||||
<p class="text-gray-500">Query string</p>
|
||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.RawQuery}}</pre>
|
||||
{{else}}
|
||||
<p class="text-gray-500">No query string.</p>
|
||||
{{end}}
|
||||
{{if .HeadersCut}}
|
||||
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
|
||||
{{else if .Headers}}
|
||||
<p class="text-gray-500">Request headers</p>
|
||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
|
||||
{{else}}
|
||||
<p class="text-gray-500">No request headers.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -3,6 +3,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}Webhooker{{end}}</title>
|
||||
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
<link rel="stylesheet" href="/s/css/style.css">
|
||||
{{block "head" .}}{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Login - Webhooker{{end}}
|
||||
{{define "title"}}Sign in - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<form method="POST" action="/pages/login" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
@@ -51,7 +52,7 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full py-3">Sign In</button>
|
||||
<button type="submit" class="btn-primary w-full py-3">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+20
-14
@@ -1,35 +1,40 @@
|
||||
{{define "navbar"}}
|
||||
<nav class="app-bar" x-data="{ open: false }">
|
||||
<nav class="app-bar" x-data="collapsible">
|
||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||
<a href="/" class="btn-secondary text-xl">Webhooker</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||
{{if .User}}
|
||||
<button type="button" @click="toggle" class="btn-secondary md:hidden p-2">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text">Webhooks</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||
<a href="/hooks" class="btn-secondary">Webhooks</a>
|
||||
<a href="/settings" class="btn-secondary">Settings</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-secondary">
|
||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
{{.User.Username}}
|
||||
</a>
|
||||
{{/* An error page can be served before a form token is issued,
|
||||
and a logout without one is refused. */}}
|
||||
{{if .CSRFToken}}
|
||||
<form method="POST" action="/pages/logout" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text">Logout</button>
|
||||
<button type="submit" class="btn-secondary">Sign out</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Login</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,14 +43,15 @@
|
||||
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex flex-col gap-2">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||
<a href="/hooks" class="btn-secondary w-full">Webhooks</a>
|
||||
<a href="/settings" class="btn-secondary w-full">Settings</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-secondary w-full">Profile</a>
|
||||
{{if .CSRFToken}}
|
||||
<form method="POST" action="/pages/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||
<button type="submit" class="btn-secondary w-full">Sign out</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "notice"}}
|
||||
{{with .Notice}}
|
||||
<div class="max-w-6xl mx-auto px-6 pt-4">
|
||||
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -41,10 +41,6 @@
|
||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
||||
<dd class="text-sm text-gray-900">Standard User</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Settings - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Settings}}
|
||||
<div class="p-4">
|
||||
<!-- A value too wide to sit beside its name moves to the
|
||||
next line, where a list breaks only at the spaces
|
||||
between its entries. wrap-anywhere breaks inside a
|
||||
value only when it alone is wider than the line. -->
|
||||
<div class="flex flex-wrap justify-between items-start gap-4">
|
||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||
<code class="text-sm text-gray-900 wrap-anywhere">{{.Value}}</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
+263
-86
@@ -3,20 +3,30 @@
|
||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||
<!-- 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. wrap-anywhere goes only on names and descriptions: a
|
||||
row too wide for a phone must still run past the edge, where the
|
||||
browser test sees it, rather than break its controls mid-word. -->
|
||||
<div class="max-w-[108rem] mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<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>
|
||||
<h1 class="text-2xl font-medium text-gray-900 wrap-anywhere">{{.Webhook.Name}}</h1>
|
||||
{{if .Webhook.Description}}
|
||||
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
|
||||
<p class="text-sm text-gray-500 mt-1 wrap-anywhere">{{.Webhook.Description}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<!-- Before a delete form is sent, static/js/app.js
|
||||
asks in the browser's own prompt, with the form's
|
||||
data-confirm text; is a line break. The
|
||||
template escapes each name for the attribute, so a
|
||||
quote in it shows as typed. -->
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" data-confirm="Delete webhook "{{.Webhook.Name}}"? This 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>
|
||||
@@ -24,13 +34,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "webhook_stats" .}}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Entrypoints -->
|
||||
<div class="card">
|
||||
<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 @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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
|
||||
@@ -38,8 +50,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Add entrypoint form -->
|
||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||
<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>
|
||||
@@ -48,35 +60,61 @@
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Entrypoints}}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<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 wrap-anywhere">{{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}}
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||
<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="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
<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="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" data-confirm="Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"? Senders 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="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||
<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}}/webhook/{{.Path}}</code>
|
||||
<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="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||
<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>
|
||||
@@ -84,92 +122,208 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Targets -->
|
||||
<div class="card">
|
||||
<!-- 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}}"
|
||||
{{if .TargetForm.ForwardQuery}}data-forward-query{{end}}
|
||||
data-max-attempts="{{.TargetForm.MaxAttempts}}"
|
||||
data-expiry="{{.TargetForm.Expiry}}"
|
||||
data-rotation="{{.TargetForm.Rotation}}">
|
||||
<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 @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 -->
|
||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" 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" x-model="targetType" 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="targetType === 'http'">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" 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="targetType === 'http'" 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="targetType !== 'http'" class="input text-sm w-24">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<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">
|
||||
<!-- 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">Archive</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>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="forward_query" value="on" :checked="forwardQuery" class="h-4 w-4">
|
||||
Pass the query string on to this target
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the URL above, after any query string the URL already has.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Delivery attempts:</label>
|
||||
<input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
|
||||
</div>
|
||||
</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>
|
||||
</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">Delivery attempts:</label>
|
||||
<input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isDatabase">
|
||||
<div class="space-y-3">
|
||||
<input type="hidden" name="type" value="database">
|
||||
<div>
|
||||
<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>
|
||||
<div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Archive rotation:</label>
|
||||
<select name="rotation" :value="rotation" class="input text-sm w-28">
|
||||
{{range .ArchiveRotationChoices}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Starts a new archive file each month, day or hour (UTC), named for it; none keeps one file.</p>
|
||||
</div>
|
||||
</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 x-show="targetType === 'slack'">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" 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="targetType === 'database'">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" 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>
|
||||
</form>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Targets}}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge-info">{{.Type}}</span>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900 wrap-anywhere">{{.Name}}</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
|
||||
{{if .Active}}
|
||||
<span class="badge-success">Active</span>
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
{{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="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
<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="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" data-confirm="Delete target "{{.Name}}"? Nothing 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="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||
<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 .Files}}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Archive size:</span>
|
||||
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</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>
|
||||
@@ -181,18 +335,41 @@
|
||||
<!-- 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">Recent Events</h2>
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||
<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 items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||
<!-- 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}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,27 +14,27 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required class="input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="label">Description</label>
|
||||
<textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
|
||||
<textarea id="description" name="description" rows="3" class="input">{{.Description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="retention_days" class="label">Retention (days)</label>
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="0" class="input">
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.RetentionDays}}" min="0" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+56
-93
@@ -1,126 +1,102 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<!-- wrap-anywhere goes only on names, IDs and content types: a row too
|
||||
wide for a phone must still run past the edge, where the browser
|
||||
test sees it, rather than break its statuses, times or controls
|
||||
mid-word. So a target's name in an event's row, which shares its
|
||||
element with the delivery's status, goes without. -->
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small wrap-anywhere">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||
<!-- Under a filter, this counts the events the filter lists. -->
|
||||
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
|
||||
</div>
|
||||
<!-- Plain links, so they work without the page's script library. The current one is marked. -->
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small{{if eq .Show ""}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show ""}} aria-current="page"{{end}}>All</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?show=failed" title="Events with at least one failed delivery" class="btn-small{{if eq .Show "failed"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "failed"}} aria-current="page"{{end}}>Failed ({{.FailedEvents}})</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?show=pending" title="Events with a delivery still pending or retrying" class="btn-small{{if eq .Show "pending"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "pending"}} aria-current="page"{{end}}>Pending ({{.PendingEvents}})</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ReplayMessage}}
|
||||
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ResubmitMessage}}
|
||||
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="{ open: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Only the newest event starts expanded. -->
|
||||
{{range $i, $event := .Events}}
|
||||
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
||||
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
||||
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||
<span class="text-sm font-mono text-gray-700 wrap-anywhere">{{.ID}}</span>
|
||||
<span class="text-sm text-gray-500 wrap-anywhere">{{.ContentType}}</span>
|
||||
{{if .ResubmittedFrom}}
|
||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||
{{end}}
|
||||
{{if .ResubmitCount}}
|
||||
<span class="text-xs text-gray-500">resubmitted {{.ResubmitCount}} time{{if ne .ResubmitCount 1}}s{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
</span>
|
||||
<span class="flex flex-wrap items-center gap-4">
|
||||
{{range .Deliveries}}
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
|
||||
{{.Target.DisplayName}}: {{.Status}}
|
||||
{{.Target.DisplayName}}{{if .Replay}} (replay){{end}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<span class="text-xs text-gray-400" title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||
<!-- The caret has no text to select, so a click on it toggles at once. -->
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" 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>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs text-gray-500">
|
||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono wrap-anywhere">{{.ResubmittedFromID}}</a>.{{end}}
|
||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||
</form>
|
||||
</div>
|
||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||
{{if .BodyTruncated}}
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
{{template "event_request" .}}
|
||||
</div>
|
||||
{{template "event_body" .Body}}
|
||||
|
||||
{{if .Deliveries}}
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||
<div class="mt-2 divide-y divide-gray-200">
|
||||
{{range .Deliveries}}
|
||||
<div class="py-2" x-data="{ attempts: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{{if .Status.Terminal}}
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="py-2" x-data="collapsible">
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" class="btn-small flex-1 gap-3 text-left" @click="toggle">
|
||||
{{template "delivery_row" .}}
|
||||
<svg class="w-3 h-3 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>
|
||||
</div>
|
||||
</button>
|
||||
{{if and .Status.Terminal (not .Target.Deleted)}}
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<!-- The list to return to. -->
|
||||
<input type="hidden" name="show" value="{{$.Show}}">
|
||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
{{range .Results}}
|
||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
||||
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
|
||||
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
||||
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
|
||||
{{end}}
|
||||
{{if .ResponseBody}}
|
||||
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
|
||||
{{end}}
|
||||
{{if .ResponseTruncated}}
|
||||
{{if .ResponseSizeKnown}}
|
||||
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
|
||||
{{else}}
|
||||
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
|
||||
{{end}}
|
||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||
{{template "delivery_attempts" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -130,22 +106,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
|
||||
<div class="p-12 text-center text-sm text-gray-500">{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{if or .HasPrev .HasNext}}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
{{if .HasPrev}}
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
{{end}}
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||
<a href="/sources/new" class="btn-primary">
|
||||
<a href="/hooks/new" class="btn-primary">
|
||||
<svg class="w-5 h-5 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>
|
||||
@@ -17,7 +17,7 @@
|
||||
{{if .Webhooks}}
|
||||
<div class="grid gap-4">
|
||||
{{range .Webhooks}}
|
||||
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
||||
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||
@@ -25,12 +25,23 @@
|
||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
||||
<!-- A label, not a control of its own: the whole card
|
||||
is the link. -->
|
||||
<span class="btn-small">Open →</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6 mt-4 text-sm text-gray-500">
|
||||
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
|
||||
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
|
||||
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
|
||||
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
|
||||
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
|
||||
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span>
|
||||
{{if .EventsUnreadable}}
|
||||
<span class="text-red-600">The event figures could not be read.</span>
|
||||
{{else}}
|
||||
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}} within retention</span>
|
||||
<span>{{with .LastEventAt}}Last event {{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}No events yet{{end}}</span>
|
||||
<span class="{{if .FailedLast24Hours}}font-medium text-red-600{{end}}">{{.FailedLast24Hours}} failed deliver{{if eq .FailedLast24Hours 1}}y{{else}}ies{{end}} in the last 24 hours</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -42,7 +53,7 @@
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
||||
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,27 +14,62 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/sources/new" class="space-y-6">
|
||||
<form method="POST" action="/hooks/new" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||
<input type="text" id="name" name="name" value="{{.Form.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="label">Description</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Description}}</textarea>
|
||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Form.Description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="retention_days" class="label">Retention (days)</label>
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.DefaultRetentionDays}}" min="0" class="input">
|
||||
<input type="number" id="retention_days" name="retention_days" value="{{.Form.RetentionDays}}" min="0" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">A periodic cleanup permanently deletes events older than this, along with their delivery records. Enter 0 to retain events forever; leave blank to use the default of {{.DefaultRetentionDays}} days.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="http_url" class="label">HTTP target URL</label>
|
||||
<input type="url" id="http_url" name="http_url" value="{{.Form.HTTPURL}}" placeholder="https://example.com/webhook" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Optional. When filled in, the webhook is created with an HTTP target that delivers each event to this URL.</p>
|
||||
</div>
|
||||
|
||||
<!-- The checkbox shows the pruning and rotation choices while
|
||||
checked. With autocomplete="off", going back to the page
|
||||
does not check the box again with the choices hidden. -->
|
||||
<div class="form-group" x-data="collapsible"{{if .Form.Archive}} data-open{{end}}>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||
Archive
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with an archive target that keeps a copy of every event.</p>
|
||||
<div x-show="open" x-cloak class="mt-3">
|
||||
<label for="archive_expiry" class="label">Archive expiry</label>
|
||||
<select id="archive_expiry" name="archive_expiry" class="input">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<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 class="mt-3">
|
||||
<label for="archive_rotation" class="label">Archive rotation</label>
|
||||
<select id="archive_rotation" name="archive_rotation" class="input">
|
||||
{{range .ArchiveRotationChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">Starts a new archive file each month, day or hour (UTC), named for it; none keeps one file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||
<a href="/sources" class="btn-secondary">Cancel</a>
|
||||
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+38
-16
@@ -5,9 +5,9 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Type: {{if eq .Target.Type "database"}}archive{{else}}{{.Target.Type}}{{end}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
@@ -17,65 +17,87 @@
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700">
|
||||
This form shows the target's stored destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
|
||||
This form shows the target's destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Target.Name}}" required class="input">
|
||||
<input type="text" id="name" name="name" value="{{.TargetForm.Name}}" required class="input">
|
||||
</div>
|
||||
|
||||
{{if eq .Target.Type "http"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Destination URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="headers" class="label">Headers</label>
|
||||
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.Target.Config.Headers}}</textarea>
|
||||
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.TargetForm.Headers}}</textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="timeout" class="label">Timeout (seconds)</label>
|
||||
<input type="number" id="timeout" name="timeout" value="{{.Target.Config.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
|
||||
<input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" id="forward_query" name="forward_query" value="on"{{if .TargetForm.ForwardQuery}} checked{{end}} class="h-4 w-4">
|
||||
Pass the query string on to this target
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the destination URL, after any query string the URL already has.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Target.Type "slack"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Webhook URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Target.Type "database"}}
|
||||
<div class="form-group">
|
||||
<label for="expiry" class="label">Archive Expiry</label>
|
||||
<input type="text" id="expiry" name="expiry" value="{{.Target.Config.Expiry}}" placeholder="never" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p>
|
||||
<label for="expiry" class="label">Archive expiry</label>
|
||||
<select id="expiry" name="expiry" class="input">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rotation" class="label">Archive rotation</label>
|
||||
<select id="rotation" name="rotation" class="input">
|
||||
{{range .ArchiveRotationChoices}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">Starts a new archive file each month, day or hour (UTC), named for it; none keeps one file. A change applies from the next event; existing files keep their names.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="form-group">
|
||||
<label for="max_retries" class="label">Max retries</label>
|
||||
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
|
||||
<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>
|
||||
<label for="max_attempts" class="label">Delivery attempts</label>
|
||||
<input type="number" id="max_attempts" name="max_attempts" value="{{.TargetForm.MaxAttempts}}" min="1" max="20" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{{define "webhook_stats"}}
|
||||
<!-- Statistics pane at the top of the webhook page. -->
|
||||
<div class="card mb-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
||||
</div>
|
||||
{{with .Stats}}
|
||||
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
||||
<div>
|
||||
<span class="text-gray-500">Entrypoints</span>
|
||||
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
||||
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Targets</span>
|
||||
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
||||
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Deliveries in progress</span>
|
||||
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Last event</span>
|
||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Retention</span>
|
||||
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<table class="w-full text-center text-gray-900">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<th></th>
|
||||
<th class="py-2 font-medium">Lifetime</th>
|
||||
<th class="py-2 font-medium">Within retention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Events</td>
|
||||
<td class="py-2">{{.Lifetime.Events}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Events}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
||||
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||
<td class="py-2">{{.Lifetime.Failures}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table class="w-full text-center text-gray-900">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<th></th>
|
||||
<th class="py-2 font-medium">Last 10 minutes</th>
|
||||
<th class="py-2 font-medium">Last 24 hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Events</td>
|
||||
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
||||
<td class="py-2">{{.Last24Hours.Events}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
||||
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
||||
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
||||
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user