Compare commits

3 Commits
Author SHA1 Message Date
clawbot 5d8e0cf3a0 Pin the HTTP target's unpinned error checks (closes #285)
check / check (push) Successful in 3m25s
withRetry's check on a failed result write could be removed with
every test still passing, and so could six other error checks in
target_http.go. Each now has a test that fails without it: the
circuit breaker learning the answer to a send whose result went
unrecorded, the result write for an invalid config, building the
request, reading the response body, decoding the target config, and
decoding the stored inbound headers.

The two backoff lookups' error checks stay unpinned: without them a
failed lookup leaves a zero time, which gives the same answer, so no
test can tell the difference.

Model: opus-5-5
2026-10-02 17:07:30 +00:00
clawbot 40f59ec4d2 Try every event database file on delete and say which was left (closes #275)
check / check (push) Successful in 3m20s
DeleteDB returned on the first event database file it failed to remove, so with WAL sidecars present a failure could be reported as "the file was left behind" after the events themselves were already gone. It now tries the database file and both sidecars, and its error says which case happened and names each file left: the database file is still on disk, or it is gone (the events are lost) and only a -wal or -shm sidecar remains. The webhook delete handler logs a different message for each and never says the events survive when they do not. Tests cover a normal delete with both sidecars, and a failed removal of the database file or a sidecar, through DeleteDB and the handler.

Model: opus-5-5
2026-10-02 18:42:29 +02:00
clawbot 806c95e305 Make every clickable control look clickable, in two shared styles (closes #375)
check / check (push) Successful in 3m16s
Many controls were plain coloured text with no sign they could be clicked: Edit, Activate, Deactivate, Delete, Replay, Resubmit, both Add controls and the copy control beside each entrypoint URL. Every clickable control is now a real button or link in one of two shared styles: the buttons in input.css, now with a pointer cursor, and btn-small, a small bordered secondary action in style.css, which the layout now loads. The site name, the footer links and each webhook list card show at rest that they open something, with focus states. The copy control shows "Copied" after a click. Rows on the webhook page and in the event log wrap at phone width. The browser test covers the copy control.

Model: opus-5-5
2026-10-02 18:32:19 +02:00
23 changed files with 650 additions and 95 deletions
+11 -10
View File
@@ -1326,15 +1326,16 @@ A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: both add forms stay hidden until Add is under the real policy and checks that: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits, also after leaving the page and going back to it, when the what it submits, also after leaving the page and going back to it, when the
browser restores the choice; an event expands and collapses, and so do a browser restores the choice; the Copy button beside an entrypoint URL reads
delivery's attempts inside it; and at phone width the menu button opens and "Copied" once clicked; an event expands and collapses, and so do a delivery's
closes the mobile menu. It also fails if the browser reports a console warning attempts inside it; and at phone width the menu button opens and closes the
or error, an uncaught exception, or anything the policy refused. `make check` mobile menu. It also fails if the browser reports a console warning or error,
and the image build lint it but do not run it, and `make test` leaves it out an uncaught exception, or anything the policy refused. `make check` and the
(its file is built only with the `browser` build tag). Run it with image build lint it but do not run it, and `make test` leaves it out (its file
`make test-browser` after changing `templates/` or `static/js/`: that builds is built only with the `browser` build tag). Run it with `make test-browser`
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser after changing `templates/` or `static/js/`: that builds `Dockerfile.browser`,
image, so the host needs no browser. which runs the test in a digest-pinned headless browser image, so the host
needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -3026,7 +3027,7 @@ webhooker/
│ ├── static.go # //go:embed directive │ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components │ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
+44 -10
View File
@@ -33,6 +33,19 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type", "invalid cached database type",
) )
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, DeliveryResults and the
@@ -151,7 +164,10 @@ func (m *WebhookDBManager) DBExists(
} }
// DeleteDB closes the connection and deletes the database file // DeleteDB closes the connection and deletes the database file
// for a webhook. The file is permanently removed. // for a webhook, with its -wal and -shm sidecars. The files are
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
func (m *WebhookDBManager) DeleteDB( func (m *WebhookDBManager) DeleteDB(
webhookID string, webhookID string,
) error { ) error {
@@ -170,16 +186,23 @@ func (m *WebhookDBManager) DeleteDB(
} }
} }
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID) path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
err := os.Remove(path + suffix) dbErr := removeFile(path)
if err != nil && !os.IsNotExist(err) { sidecarErr := errors.Join(
return fmt.Errorf( removeFile(path+"-wal"),
"deleting webhook database file %s%s: %w", removeFile(path+"-shm"),
path, suffix, err, )
)
} if dbErr != nil {
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
} }
m.log.Info( m.log.Info(
@@ -190,6 +213,17 @@ func (m *WebhookDBManager) DeleteDB(
return nil return nil
} }
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections. // CloseAll closes all open per-webhook database connections.
// Called during application shutdown. // Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error { func (m *WebhookDBManager) CloseAll() error {
+77 -3
View File
@@ -182,17 +182,91 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
} }
require.NoError(t, db.Create(event).Error) require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB // Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID)) require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist // File should no longer exist
assert.False(t, mgr.DBExists(webhookID)) assert.False(t, mgr.DBExists(webhookID))
// Verify the file is actually gone from disk // Verify the files are actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID) dbPath := mgr.DBPath(webhookID)
_, err = os.Stat(dbPath) blockRemoval(t, dbPath)
assert.True(t, os.IsNotExist(err)) require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
} }
func TestWebhookDBManager_LazyCreation(t *testing.T) { func TestWebhookDBManager_LazyCreation(t *testing.T) {
@@ -1425,6 +1425,32 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
) )
} }
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching --- // --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
+71
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -1056,6 +1057,21 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
) )
} }
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1241,6 +1257,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
) )
} }
// A response that ends before the length it announced is an error, not
// a short body.
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
)
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
}
// The event's stored inbound headers carry the same Content-Type the // The event's stored inbound headers carry the same Content-Type the
// receiver saved as the event's ContentType, so a delivery could send // receiver saved as the event's ContentType, so a delivery could send
// it twice. It must go out exactly once, with a Content-Type configured // it twice. It must go out exactly once, with a Content-Type configured
@@ -1317,6 +1360,34 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
} }
} }
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
}
func TestProcessDelivery_RoutesToCorrectHandler( func TestProcessDelivery_RoutesToCorrectHandler(
t *testing.T, t *testing.T,
) { ) {
@@ -376,3 +376,97 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
} }
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
+21
View File
@@ -179,6 +179,27 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
) )
} }
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
// target URL that no request can be built from.
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n",
},
&database.Event{},
)
require.Error(t, reqErr)
assert.Zero(t, statusCode)
assertNoCredential(t, reqErr.Error())
assert.Contains(t, reqErr.Error(), "invalid control character")
}
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF // TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
// validator's error does not carry the submitted URL, which // validator's error does not carry the submitted URL, which
// the handler both logs and shows. // the handler both logs and shows.
+9
View File
@@ -36,6 +36,15 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
// the handlers enforce rather than a number copied beside it. // the handlers enforce rather than a number copied beside it.
const MaxTargetRetriesForTest = maxTargetRetries const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event
// database is left on disk, so a test checking that one is absent
// checks for the handler's own wording.
const (
EventDBLeftMsgForTest = eventDBLeftMsg
SidecarLeftMsgForTest = sidecarLeftMsg
)
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test // PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package. // package.
func PageOrFirstForTest(s string) int { func PageOrFirstForTest(s string) int {
+117
View File
@@ -1,8 +1,10 @@
package handlers_test package handlers_test
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -466,6 +468,121 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
) )
} }
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
// database file is removed but a sidecar beside it is not, the
// operator is told the events are gone, never that the event
// database file is still there.
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
require.NoError(t, mgr.CreateDB(wh.ID))
// Closing removes the sidecars, so the -wal below is the only
// one there.
require.NoError(t, mgr.CloseAll())
// A non-empty directory in the -wal file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NoFileExists(t, eventDBPath)
assert.Contains(t, logs.String(), "its events are gone")
assert.Contains(t, logs.String(), eventDBPath+"-wal")
assert.NotContains(
t, logs.String(), handlers.EventDBLeftMsgForTest,
"the events are gone, so the operator must not be told "+
"the event database file survived",
)
}
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
// event database file itself cannot be removed, the operator is told
// it is still on disk, never that its events are gone.
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
// A non-empty directory in the database file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath, "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(
t, logs.String(), "event database file is still on disk",
)
assert.Contains(t, logs.String(), eventDBPath)
assert.NotContains(
t, logs.String(), handlers.SidecarLeftMsgForTest,
"the database file is still on disk, so the operator must "+
"not be told its events are gone",
)
}
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a // TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// database target releases that target's archive writer and no // database target releases that target's archive writer and no
// other: the webhook's other database target keeps its own. // other: the webhook's other database target keeps its own.
+20 -4
View File
@@ -723,6 +723,17 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
} }
} }
// The messages deleteWebhookResources logs when a file of the event
// database cannot be removed: the database file itself, or only a
// sidecar once the database file is gone.
const (
eventDBLeftMsg = "webhook deleted, but its event database file is " +
"still on disk; remove it by hand"
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
"-wal or -shm sidecar of its event database is " +
"still on disk; remove it by hand"
)
// deleteWebhookResources soft-deletes config and hard-deletes // deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database. // the per-webhook event database.
func (h *Handlers) deleteWebhookResources( func (h *Handlers) deleteWebhookResources(
@@ -762,13 +773,18 @@ func (h *Handlers) deleteWebhookResources(
err = h.dbMgr.DeleteDB(webhook.ID) err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil { if err != nil {
// The configuration is committed, so the webhook is gone, // The configuration is committed, so the webhook is gone,
// but its event database file is still on disk with // but a file of its event database is still on disk with
// nothing referencing it. Report the failure rather than // nothing referencing it. Report the failure rather than
// redirecting as though everything succeeded: the file // redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it. // needs removing by hand, and the logged error names it.
h.serverError( // When only a sidecar is left, the events are already
w, r, "failed to delete webhook event database", err, // gone, and the message must not suggest they survive.
) msg := eventDBLeftMsg
if errors.Is(err, database.ErrSidecarNotRemoved) {
msg = sidecarLeftMsg
}
h.serverError(w, r, msg, err)
return return
} }
+6 -5
View File
@@ -82,9 +82,9 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
}) })
assert.Contains(t, body, "Retention: 14 days") assert.Contains(t, body, "Retention: 14 days")
assert.Contains(t, body, `class="btn-text">Webhooks</a>`) assert.Contains(t, body, `class="btn-secondary">Webhooks</a>`)
assert.Contains( assert.Contains(
t, body, `class="btn-text w-full text-left">Webhooks</a>`, t, body, `class="btn-secondary w-full">Webhooks</a>`,
) )
assert.Contains( assert.Contains(
t, body, t, body,
@@ -163,7 +163,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
) )
assert.Contains( assert.Contains(
t, detailBody, t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`, `<a href="/hook/wh-1/events" class="btn-small">Full Event Log</a>`,
"the link under recent events", "the link under recent events",
) )
@@ -331,8 +331,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
assert.Contains(t, body, "https://hooks.example.com/h/abc123") assert.Contains(t, body, "https://hooks.example.com/h/abc123")
assert.Contains( assert.Contains(
t, body, t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`, `<button type="button" hidden data-copy-target="entrypoint-url-ep-1"`,
"the button must start hidden and be revealed by script", "the copy control must be a button, start hidden and be "+
"revealed by script",
) )
// renderTemplate streams to the ResponseWriter, so an abort // renderTemplate streams to the ResponseWriter, so an abort
+37 -1
View File
@@ -17,6 +17,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
@@ -43,7 +44,7 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the // TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so // event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages' // under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work. // Alpine.js directives and the copy control work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -55,6 +56,13 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: webhook.ID,
Path: "3c9e1f7a-5b2d-4e8a-9f6c-2a7d1e4b8c05",
Active: true,
},
).Error)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`) event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID) target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID) dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
@@ -77,6 +85,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkAddForms(ctx, t, page) checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events") checkTargetType(ctx, t, page+"/events")
checkCopy(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
@@ -220,6 +229,8 @@ func click(ctx context.Context, t *testing.T, xpath string) {
// checkAddForms loads a webhook page and checks that each section's add // checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked. // form stays hidden until the Add button beside its heading is clicked.
// The click looks for a button element there, so it also checks that
// Add is one.
func checkAddForms(ctx context.Context, t *testing.T, url string) { func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
@@ -327,6 +338,31 @@ func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
) )
} }
// checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded.
func checkCopy(ctx context.Context, t *testing.T, url string) {
t.Helper()
copyButton := `//button[@data-copy-target]`
// A browser lets the page in its active tab write to the clipboard
// on a click. A headless browser refuses unless told to allow it.
require.NoError(t, chromedp.Run(
ctx,
browser.SetPermission(
&browser.PermissionDescriptor{Name: "clipboard-write"},
browser.PermissionSettingGranted,
),
loadPage(url),
))
click(ctx, t, copyButton)
assert.True(t, shown(ctx, copyButton+`[text()="Copied"]`),
`clicking Copy does not show "Copied"`)
}
// checkEventLog loads the event log and checks that clicking an event's // checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the // row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking // delivery's attempts and clicking again hides them, and that clicking
+2 -2
View File
@@ -1220,12 +1220,12 @@ func TestHook_LinksBetweenPages(t *testing.T) {
// mobile menu link. // mobile menu link.
{ {
"/user/navigator/", "/user/navigator/",
`href="([^"]+)" class="btn-text">Webhooks<`, `href="([^"]+)" class="btn-secondary">Webhooks<`,
list, list,
}, },
{ {
"/user/navigator/", "/user/navigator/",
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`, `href="([^"]+)" class="btn-secondary w-full">Webhooks<`,
list, list,
}, },
{list, `href="(/hook/[^"]+)"`, page}, {list, `href="(/hook/[^"]+)"`, page},
+48 -1
View File
@@ -1 +1,48 @@
/* Webhooker custom styles — see input.css for Tailwind theme */ /*
* The two shared styles for the controls a user clicks. Every page loads
* this file after tailwind.css. It is plain CSS: make css does not build
* it.
*
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
* A secondary or inline action, such as Copy beside an entrypoint URL or
* Edit beside a target, is btn-small.
*
* Each card on the webhook list is a card-elevated link as a whole. It
* shows an Open label in btn-small and takes btn-small's focus outline.
*
* The rules join tailwind.css's components layer, where input.css puts
* its own, so a utility class on an element still overrides them.
*/
@layer components {
/* input.css gives its buttons no pointer cursor. */
.btn-primary,
.btn-secondary,
.btn-danger {
cursor: pointer;
}
.btn-small {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
background-color: var(--color-white);
color: var(--color-primary-700);
font-size: var(--text-xs);
line-height: 1rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn-small:hover {
border-color: var(--color-primary-500);
background-color: var(--color-primary-50);
}
.btn-small:focus-visible,
.card-elevated:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
}
+2 -2
View File
@@ -22,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"> <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="max-w-6xl mx-auto px-8 py-6">
<div class="text-center text-sm text-gray-500 font-mono font-light"> <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> <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 class="mx-3">|</span>
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span> <span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
</div> </div>
+1
View File
@@ -3,6 +3,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Webhooker{{end}}</title> <title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css"> <link rel="stylesheet" href="/s/css/tailwind.css">
<link rel="stylesheet" href="/s/css/style.css">
<style>[x-cloak] { display: none !important; }</style> <style>[x-cloak] { display: none !important; }</style>
{{block "head" .}}{{end}} {{block "head" .}}{{end}}
{{end}} {{end}}
+10 -10
View File
@@ -2,12 +2,12 @@
<nav class="app-bar" x-data="collapsible"> <nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3"> <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> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
{{if .User}} {{if .User}}
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"> <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"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="closed" 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"/> <path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -18,9 +18,9 @@
<!-- Desktop navigation --> <!-- Desktop navigation -->
<div class="hidden md:flex items-center gap-4"> <div class="hidden md:flex items-center gap-4">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a> <a href="/hooks" class="btn-secondary">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a> <a href="/settings" class="btn-secondary">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text"> <a href="/user/{{.User.Username}}" class="btn-secondary">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16"> <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 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"/> <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"/>
@@ -32,7 +32,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-secondary">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
@@ -43,13 +43,13 @@
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200"> <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"> <div class="flex flex-col gap-2">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a> <a href="/hooks" class="btn-secondary w-full">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a> <a href="/settings" class="btn-secondary w-full">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-secondary w-full">Profile</a>
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <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">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
+16 -16
View File
@@ -9,7 +9,7 @@
no class this wide. --> no class this wide. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem"> <div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
@@ -35,8 +35,8 @@
<div class="card" x-data="collapsible"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <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> <h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="toggle" class="btn-text text-sm"> <button type="button" @click="toggle" class="btn-small">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
Add Add
@@ -55,9 +55,9 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Entrypoints}} {{range .Entrypoints}}
<div class="p-4"> <div class="p-4">
<div class="flex items-center justify-between mb-1"> <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> <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="flex flex-wrap items-center gap-2">
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
@@ -65,13 +65,13 @@
{{end}} {{end}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <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}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline"> <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}}"> <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> </form>
</div> </div>
</div> </div>
@@ -79,7 +79,7 @@
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.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 <!-- Hidden until app.js reveals it; without the
script the URL above stays selectable. --> 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> </div>
<!-- The URL above is the entrypoint's credential: <!-- The URL above is the entrypoint's credential:
anyone holding it can submit events. --> anyone holding it can submit events. -->
@@ -94,8 +94,8 @@
<div class="card" x-data="collapsible"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <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> <h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="toggle" class="btn-text text-sm"> <button type="button" @click="toggle" class="btn-small">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
Add Add
@@ -148,25 +148,25 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Targets}} {{range .Targets}}
<div class="p-4"> <div class="p-4">
<div class="flex items-center justify-between mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span> <span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge-info">{{.Type}}</span> <span class="badge-info">{{.Type}}</span>
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
<span class="badge-error">Inactive</span> <span class="badge-error">Inactive</span>
{{end}} {{end}}
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a> <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"> <form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <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}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline"> <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}}"> <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> </form>
</div> </div>
</div> </div>
@@ -188,7 +188,7 @@
<div class="card mt-6"> <div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <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> <h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a> <a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div> </div>
+29 -27
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span> <span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
@@ -16,8 +16,8 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle"> <button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle">
<div class="flex items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</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 text-gray-500">{{.ContentType}}</span>
@@ -27,8 +27,8 @@
{{if .ResubmitCount}} {{if .ResubmitCount}}
<span class="text-xs text-gray-500">resubmitted {{.ResubmitCount}} time{{if ne .ResubmitCount 1}}s{{end}}</span> <span class="text-xs text-gray-500">resubmitted {{.ResubmitCount}} time{{if ne .ResubmitCount 1}}s{{end}}</span>
{{end}} {{end}}
</div> </span>
<div class="flex items-center gap-4"> <span class="flex flex-wrap items-center gap-4">
{{range .Deliveries}} {{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}}"> <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}}: {{.Status}}
@@ -38,8 +38,8 @@
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</div> </span>
</div> </button>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md"> <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="mb-3 flex flex-wrap items-center justify-between gap-2">
@@ -50,12 +50,12 @@
<form method="POST" action="/hook/{{$.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="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}"> <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> </form>
</div> </div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre> <pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}} {{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 &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p> <p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
{{end}} {{end}}
{{if .Deliveries}} {{if .Deliveries}}
@@ -64,24 +64,26 @@
<div class="mt-2 divide-y divide-gray-200"> <div class="mt-2 divide-y divide-gray-200">
{{range .Deliveries}} {{range .Deliveries}}
<div class="py-2" x-data="collapsible"> <div class="py-2" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle"> <div class="flex items-center gap-3">
<div class="flex items-center gap-3"> <button type="button" class="btn-small flex-1 flex-wrap justify-between gap-2 text-left" @click="toggle">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span> <span class="flex flex-wrap items-center gap-3">
<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> <span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
</div> <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 class="flex items-center gap-3"> </span>
{{if .Status.Terminal}} <span class="flex flex-wrap items-center gap-3">
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop> <span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<input type="hidden" name="page" value="{{$.Page}}"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button> </svg>
</form> </span>
{{end}} </button>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span> {{if .Status.Terminal}}
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
</svg> <input type="hidden" name="page" value="{{$.Page}}">
</div> <button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form>
{{end}}
</div> </div>
<div x-show="open" x-cloak class="mt-2 space-y-2"> <div x-show="open" x-cloak class="mt-2 space-y-2">
+6 -1
View File
@@ -25,7 +25,12 @@
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p> <p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
{{end}} {{end}}
</div> </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 &rarr;</span>
</div>
</div> </div>
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500"> <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>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1> <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: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div> </div>