From d7107f1f9e9958566607ce5ce10ede33c88f65d4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 00:53:21 +0000 Subject: [PATCH] Event log: only the newest event expanded, and only the 50 most recent (closes #349) The event log now loads the 50 newest events in its query and opens only the newest on load, as the recent events on a webhook's page already do. With 50 at most there is never a second page, so paging is gone: the page links, the `page` query parameter, the page number that Replay and Resubmit carried back to the log, and `pageOrFirst` with its test. The count beside the heading reads "50 most recent of N events" when there are more. The comments and README lines that named `page` as the query parameter the service reads now name `next` and `notice`. Model: opus-5-5 --- README.md | 28 ++++---- internal/handlers/delivery_replay.go | 20 ++---- .../handlers/delivery_result_view_test.go | 8 +-- internal/handlers/event_log_view_test.go | 4 +- internal/handlers/export_test.go | 9 +-- internal/handlers/handlers.go | 5 +- internal/handlers/page_title_test.go | 6 +- internal/handlers/source_logs_test.go | 55 +++++++++++++++ internal/handlers/source_management.go | 69 ++++--------------- internal/handlers/target_retries_test.go | 17 ----- internal/handlers/ui_copy_test.go | 21 +++--- internal/middleware/middleware.go | 13 ++-- internal/server/alpine_browser_test.go | 44 +++++++----- internal/server/routes_test.go | 14 +--- internal/server/sentry.go | 5 +- static/css/tailwind.css | 2 +- templates/source_logs.html | 22 ++---- 17 files changed, 157 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 2b2f6bc..5576bf9 100644 --- a/README.md +++ b/README.md @@ -1364,17 +1364,18 @@ an entrypoint's Edit button shows its edit form in place of its description and hides until the form closes, Cancel hides the form and drops what was typed, as does leaving the page and going back to it, and Save changes the description; of the recent events on the webhook page only the newest starts expanded, each -expands and collapses, and Open leads to the event's own page; an event in the -event log expands and collapses when its row's caret or its ID is clicked, and -from the keyboard, but not when its ID is selected with the mouse, and a -delivery's attempts inside it expand and collapse; and at phone width the menu -button opens and closes the mobile menu. It also fails if the browser reports a -console warning or error, an uncaught exception, or anything the policy refused. -`make check` and the image build lint it but do not run it, and `make test` -leaves it out (its file is built only with the `browser` build tag). Run it with -`make test-browser` after changing `templates/` or `static/js/`: that builds -`Dockerfile.browser`, which runs the test in a digest-pinned headless browser -image, so the host needs no browser. +expands and collapses, and Open leads to the event's own page; of the events in +the event log only the newest starts expanded, and an event there expands and +collapses when its row's caret or its ID is clicked, and from the keyboard, but +not when its ID is selected with the mouse, and a delivery's attempts inside it +expand and collapse; and at phone width the menu button opens and closes the +mobile menu. It also fails if the browser reports a console warning or error, an +uncaught exception, or anything the policy refused. `make check` and the image +build lint it but do not run it, and `make test` leaves it out (its file is +built only with the `browser` build tag). Run it with `make test-browser` after +changing `templates/` or `static/js/`: that builds `Dockerfile.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 byte as the npm registry publishes it. It is a dependency, not this repo's build @@ -2536,8 +2537,9 @@ The query string is never logged; it is replaced by the fixed marker `/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate limiter in front of them, so a query on a fixed 200 URL would otherwise buy the same amplification as an invented path. Nothing debuggable is -lost: `page`, on the authenticated pagination links, is the only query -parameter this service reads. +lost: the only query parameters this service reads are the login page's +`next`, the page to return to, and `notice`, which names the line a page +shows after an action. Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index be4c6d6..4d0473a 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strconv" "github.com/go-chi/chi" "gorm.io/gorm" @@ -329,24 +328,15 @@ func replayBody(body string) *string { } // redirectToEventLog redirects a replay or resubmit back to the event -// log it was triggered from, carrying the outcome as its notice and -// the page number the form submitted. +// log it was triggered from, carrying the outcome as its notice. func redirectToEventLog( w http.ResponseWriter, r *http.Request, webhook database.Webhook, code noticeCode, ) { - dest := withNotice("/hook/"+webhook.ID+"/events", code) - - // The page is read from the form rather than the query string: - // this is a POST, and its query string is what logs and Referer - // headers record. - if page := pageOrFirst( - r.PostFormValue("page"), - ); page > 1 { - dest += "&page=" + strconv.Itoa(page) - } - - http.Redirect(w, r, dest, http.StatusSeeOther) + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID+"/events", code), + http.StatusSeeOther, + ) } diff --git a/internal/handlers/delivery_result_view_test.go b/internal/handlers/delivery_result_view_test.go index 8b4a388..66fc386 100644 --- a/internal/handlers/delivery_result_view_test.go +++ b/internal/handlers/delivery_result_view_test.go @@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) { }).Error) } - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) @@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) { stored := strings.Repeat("A", responseCap*4) + tail seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries[0].Results, 1) diff --git a/internal/handlers/event_log_view_test.go b/internal/handlers/event_log_view_test.go index 4c5b0e4..a906d4d 100644 --- a/internal/handlers/event_log_view_test.go +++ b/internal/handlers/event_log_view_test.go @@ -75,9 +75,7 @@ func seedAndProject( wh := seedWebhook(t, db) seedEventWithBody(t, dbMgr, wh.ID, body) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) return views[0] diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index a635d55..649e092 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -51,12 +51,6 @@ const ( SidecarLeftMsgForTest = sidecarLeftMsg ) -// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test -// package. -func PageOrFirstForTest(s string) int { - return pageOrFirst(s) -} - // DummyVerificationsForTest reports how many equivalent-cost // verifications were charged for usernames that do not exist. It // lets a test prove the anti-enumeration path ran without timing @@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte { func (s *Handlers) LoadEventLogViewsForTest( w http.ResponseWriter, webhook database.Webhook, - page int, ) []EventLogView { views, _, _ := s.loadEventsWithDeliveries( - w, newRequestForTest(), webhook, nil, page, + w, newRequestForTest(), webhook, nil, ) return views diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c16173c..151d8c4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -30,10 +30,9 @@ import ( const ( // maxBodyShift is the bit shift for 1 MB body limit. maxBodyShift = 20 - // recentEventLimit is the number of recent events to show. + // recentEventLimit is the number of most recent events that a + // webhook's page and its event log show. recentEventLimit = 50 - // paginationPerPage is the number of items per page. - paginationPerPage = 25 // tmplKeyError is the template data key for an error message. tmplKeyError = "Error" diff --git a/internal/handlers/page_title_test.go b/internal/handlers/page_title_test.go index 81bd9a6..8c11f98 100644 --- a/internal/handlers/page_title_test.go +++ b/internal/handlers/page_title_test.go @@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) { }, { "source_logs.html", - map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)}, + map[string]any{ + dataKeyWebhook: webhook, + dataKeyEvents: []handlers.EventLogView{}, + "TotalEvents": int64(0), + }, "Full Event Log - orders - Webhooker", }, { diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index d0b5b78..79cdaff 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -2,9 +2,12 @@ package handlers_test import ( "context" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" @@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) { assert.Contains(t, body, tgt.Name) assert.Contains(t, body, "delivered") } + +// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log +// holds the 50 newest events, newest first, and not one more, and says +// how many events there are in all. +func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + f.event( + t, fmt.Sprintf("application/x-log-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + } + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 50, strings.Count(body, `role="button"`)) + assert.NotContains(t, body, "application/x-log-00") + assert.Contains(t, body, "application/x-log-01") + assert.Less( + t, + strings.Index(body, "application/x-log-50"), + strings.Index(body, "application/x-log-49"), + ) + assert.Contains(t, body, "50 most recent of 51 events") +} + +// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the +// events in the log only the newest starts expanded. +func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + now := time.Now() + + f.event(t, "application/x-older", "{}", now.Add(-time.Minute)) + f.event(t, "application/x-newer", "{}", now) + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 1, strings.Count(body, " data-open>")) + + open := strings.Index(body, " data-open>") + newer := strings.Index(body, "application/x-newer") + older := strings.Index(body, "application/x-older") + + assert.Less(t, open, newer, "the newest event is not the open one") + assert.Less(t, newer, older) +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 01dcb50..a5ed931 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1112,30 +1112,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc { return } - page := h.parsePage(r) - evts, total, ok := h.loadEventsWithDeliveries( - w, r, webhook, targets, page, + w, r, webhook, targets, ) if !ok { return } - totalPages := int(total) / paginationPerPage - if int(total)%paginationPerPage != 0 { - totalPages++ - } - data := map[string]any{ tmplKeyWebhook: &webhook, "Events": evts, - "Page": page, - "TotalPages": totalPages, "TotalEvents": total, - "HasPrev": page > 1, - "HasNext": page < totalPages, - "PrevPage": page - 1, - "NextPage": page + 1, } h.renderTemplate(w, r, "source_logs.html", data) @@ -1195,15 +1182,11 @@ func (h *Handlers) loadTargetMap( return targetMap, nil } -// parsePage extracts a page number from the query string. -func (h *Handlers) parsePage(r *http.Request) int { - return pageOrFirst(r.URL.Query().Get("page")) -} - -// loadEventsWithDeliveries loads paginated events and their -// deliveries from the per-webhook database. Events come back -// as capped projections rather than database.Event rows: see -// eventLogColumns for why the cut happens in SQL. +// loadEventsWithDeliveries loads the recentEventLimit newest events +// and their deliveries from the per-webhook database, and the total +// number of events stored. Events come back as capped projections +// rather than database.Event rows: see eventLogColumns for why the +// cut happens in SQL. // // The bool reports whether the load succeeded. It is false // once this has answered the request with an error, and the @@ -1213,7 +1196,6 @@ func (h *Handlers) loadEventsWithDeliveries( r *http.Request, webhook database.Webhook, targetMap map[string]eventLogTarget, - page int, ) ([]EventLogView, int64, bool) { if !h.dbMgr.DBExists(webhook.ID) { return nil, 0, true @@ -1228,9 +1210,7 @@ func (h *Handlers) loadEventsWithDeliveries( return nil, 0, false } - rows, totalEvents := loadEventLogRows( - webhookDB, webhook.ID, page, - ) + rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID) result, ok := h.eventLogViews( w, r, webhookDB, webhook.ID, rows, targetMap, @@ -1303,10 +1283,11 @@ func (h *Handlers) eventLogViews( return result, true } -// loadEventLogRows reads one page of the event log projection, newest -// first, and the total number of events the pager counts against. +// loadEventLogRows reads the event log projection of the +// recentEventLimit newest events, newest first, and the total number +// of events stored. func loadEventLogRows( - webhookDB *gorm.DB, webhookID string, page int, + webhookDB *gorm.DB, webhookID string, ) ([]eventLogRow, int64) { var totalEvents int64 @@ -1320,9 +1301,7 @@ func loadEventLogRows( eventLogColumns, maxRenderedBodyBytes, ).Where( "webhook_id = ?", webhookID, - ).Order("created_at DESC").Offset( - (page - 1) * paginationPerPage, - ).Limit(paginationPerPage).Find(&rows) + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows) return rows, totalEvents } @@ -1331,9 +1310,9 @@ func loadEventLogRows( // events have been resubmitted from it. // // One grouped query covers the page rather than one query per event. -// A page holds paginationPerPage ids, far below SQLite's bound -// parameter ceiling, so it needs no chunking as the delivery result -// load does. +// The page shows at most recentEventLimit events, far below SQLite's +// bound parameter ceiling, so it needs no chunking as the delivery +// result load does. func resubmitCounts( webhookDB *gorm.DB, eventIDs []string, ) (map[string]int, error) { @@ -1759,24 +1738,6 @@ func (h *Handlers) setTargetFromForm( return "", nil } -// pageOrFirst parses a paginated page number, answering 1 for -// anything empty, unparseable or out of range. -// -// Falling back rather than rejecting is correct here and only here: -// a page number is where to send the browser next, not configuration -// the operator is storing, and the actions that submit one have -// already completed by the time it is read — answering 400 would -// report a failure that did not happen. Anything an operator SETS -// must be validated instead; see parseMaxRetries. -func pageOrFirst(s string) int { - v, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || v < 1 { - return 1 - } - - return v -} - // targetFormInput carries the raw values of a target form. Both the // create and the edit path fill one and hand it to setTargetFromForm, // so neither can come to validate a target differently from the diff --git a/internal/handlers/target_retries_test.go b/internal/handlers/target_retries_test.go index db8f539..686af04 100644 --- a/internal/handlers/target_retries_test.go +++ b/internal/handlers/target_retries_test.go @@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) { ) } } - -// TestPageOrFirst_CoercesRatherThanRejects pins the one place a -// non-numeric form value legitimately falls back. A page number says -// where to send the browser after an action that has already -// happened, so it is not configuration and rejecting it would report -// a failure that did not occur. -func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) { - t.Parallel() - - for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} { - assert.Equal(t, 1, handlers.PageOrFirstForTest(s), - "%q should fall back to the first page", s) - } - - assert.Equal(t, 4, handlers.PageOrFirstForTest("4")) - assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 ")) -} diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 28eca73..21800d6 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -21,6 +21,7 @@ import ( const ( dataKeyWebhook = "Webhook" dataKeyError = "Error" + dataKeyEvents = "Events" ) // testWebhookID is the identifier given to the webhook under test on @@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) { t.Cleanup(app.RequireStop) // A pointer, as in the handlers: source_detail.html calls - // Webhook.RetentionLabel, a pointer method. Both pages only range - // over their lists, and a list left out renders as empty, so the - // lists are left out. + // Webhook.RetentionLabel, a pointer method. The webhook page only + // ranges over its lists, and a list left out renders as empty, so + // its lists are left out. The event log also counts its events, so + // it gets an empty list. webhook := &database.Webhook{Name: "wh", RetentionDays: 14} webhook.ID = testWebhookID @@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) { logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{ dataKeyWebhook: webhook, + dataKeyEvents: []handlers.EventLogView{}, "TotalEvents": int64(0), }) @@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { "Entrypoints": handlers.NewEntrypointViews( []database.Entrypoint{entrypoint}, ), - "Targets": delivery.NewTargetViews(nil), - "Events": []database.Event{}, - "BaseURL": "https://hooks.example.com", + "Targets": delivery.NewTargetViews(nil), + dataKeyEvents: []database.Event{}, + "BaseURL": "https://hooks.example.com", }) assert.Contains( @@ -384,9 +387,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { "Entrypoints": handlers.NewEntrypointViews( []database.Entrypoint{entrypoint}, ), - "Targets": delivery.NewTargetViews(nil), - "Events": []database.Event{}, - "BaseURL": "https://hooks.example.com", + "Targets": delivery.NewTargetViews(nil), + dataKeyEvents: []database.Event{}, + "BaseURL": "https://hooks.example.com", }, ) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index a1a312b..3c2e3ff 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter { // after the '?'. Keeping the path and dropping the query is what makes // this branch as bounded as the pattern branches below. // -// Nothing debuggable is lost. One route in the service reads a query -// parameter at all — `page`, on the authenticated pagination links in -// internal/handlers/source_management.go — and the alternatives that -// would preserve more (a key count, a key allowlist) all require -// parsing an attacker-sized query on every request, which is work an -// unauthenticated client would then be choosing for us. +// Nothing debuggable is lost. The only query parameters the service +// reads are the login page's `next`, the page to return to, and +// `notice`, which names the line a page shows after an action. The +// alternatives that would preserve more (a key count, a key +// allowlist) all require parsing an attacker-sized query on every +// request, which is work an unauthenticated client would then be +// choosing for us. func concreteLogURL(r *http.Request) string { path := r.URL.EscapedPath() diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 49db16a..5467d88 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -765,14 +765,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { "the event's own page does not show its body") } -// checkEventLog loads the event log and checks an event's row. Clicking -// its ID expands the event, and in there clicking its delivery shows the -// delivery's attempts and clicking again hides them. Clicking the row's -// caret collapses the event, clicking it again expands it, and clicking -// the ID again collapses it. While the event is expanded the row says so -// and its caret is turned up, and while it is collapsed neither. It then -// runs checkEventSelection on the log's last event, lastEventID, and -// checkEventKeyboard on eventID. +// checkEventLog loads the event log and checks that of its events only +// the newest, eventID, starts expanded: its row says so and its caret is +// turned up, and the log's last event, lastEventID, starts collapsed. In +// the newest event, clicking its delivery shows the delivery's attempts +// and clicking again hides them. Clicking the row's caret collapses the +// event, clicking it again expands it, clicking its ID collapses it and +// clicking the ID again expands it. While the event is collapsed the row +// says so and its caret is turned down. It then runs checkEventSelection +// on lastEventID and checkEventKeyboard on eventID. func checkEventLog( ctx context.Context, t *testing.T, @@ -790,20 +791,19 @@ func checkEventLog( caretUp := caret + `[contains(@class, "rotate-180")]` caretDown := caret + `[not(contains(@class, "rotate-180"))]` expanded := `form[action$="/` + eventID + `/resubmit"]` + lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]` deliveryRow := `//span[text()="` + targetName + `"]` attempt := `//span[text()="Attempt 1"]` require.NoError(t, chromedp.Run(ctx, loadPage(url))) - assert.True(t, hidden(ctx, expanded), "the event starts expanded") - - click(ctx, t, id) - assert.True(t, shown(ctx, expanded), - "clicking the event's ID does not expand it") + assert.True(t, shown(ctx, expanded), "the newest event starts collapsed") assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`), "the expanded event's row does not say it is expanded") assert.True(t, shown(ctx, caretUp), "the expanded event's caret does not turn up") + assert.True(t, hidden(ctx, lastExpanded), + "an older event starts expanded") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") @@ -829,7 +829,11 @@ func checkEventLog( click(ctx, t, id) assert.True(t, hidden(ctx, expanded), - "clicking the event's ID again does not collapse it") + "clicking the event's ID does not collapse it") + + click(ctx, t, id) + assert.True(t, shown(ctx, expanded), + "clicking the event's ID again does not expand it") checkEventSelection(ctx, t, url, lastEventID) checkEventKeyboard(ctx, t, url, eventID) @@ -878,8 +882,11 @@ func checkEventSelection( `window.getSelection().removeAllRanges()`, nil, ) + // The newest event starts expanded, which can push this one below + // the short window, where the mouse cannot reach it. require.NoError(t, chromedp.Run( ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url), + chromedp.ScrollIntoView(id, chromedp.BySearch), )) selectText(ctx, t, id) @@ -922,8 +929,9 @@ func checkEventSelection( } // checkEventKeyboard loads the event log and checks that Tab from the -// page's Back link reaches the event's row, the first after it, and that -// Enter then expands the event and Space collapses it. +// page's Back link reaches the row of the newest event, the first after +// it, and that Enter then collapses that event, which starts expanded, +// and Space expands it again. func checkEventKeyboard( ctx context.Context, t *testing.T, url, eventID string, ) { @@ -945,10 +953,10 @@ func checkEventKeyboard( "Tab from the Back link does not reach the event's row") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) - assert.True(t, shown(ctx, expanded), "Enter does not expand the event") + assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) - assert.True(t, hidden(ctx, expanded), "Space does not collapse the event") + assert.True(t, shown(ctx, expanded), "Space does not expand the event") } // selectText selects the text of the element matching an XPath diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index dac68de..3d0dd62 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) { // TestHook_LinksBetweenPages follows each link to a webhook page that // the tests above do not: the navbar's "Webhooks" links, the back and -// Cancel links, the list's link to a webhook, the "Full Event Log" -// link beside the recent events, and the event log's page links. Each -// must point where it should, and that page must render. +// Cancel links, the list's link to a webhook, and the "Full Event Log" +// link beside the recent events. Each must point where it should, and +// that page must render. func TestHook_LinksBetweenPages(t *testing.T) { t.Parallel() @@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) { wh := env.seedWebhook(t, userID) tgt := env.seedTarget(t, wh.ID) - // The event log shows 25 events a page; one more gives it a second - // page, so it renders its Next and Previous links. - for range 26 { - env.seedEvent(t, wh.ID, "paged") - } - list := "/hooks" newForm := list + "/new" page := "/hook/" + wh.ID @@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) { {targetEdit, back, page}, {targetEdit, cancel, page}, {events, back, page}, - {events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"}, - {events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"}, } { got := env.urlFrom(t, link.from, link.pattern, cookies) assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern) diff --git a/internal/server/sentry.go b/internal/server/sentry.go index 9056246..b02dc68 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string { // sentryKeepsHeader reports whether a request header is routing or // content metadata rather than client-chosen payload. Referer is kept -// on the reasoning that it is browser-set, that this service emits -// only ?page= in its own links, and that Referrer-Policy is set to +// on the reasoning that it is browser-set, that the only query +// parameters in this service's own URLs are the login page's `next` +// and `notice`, and that Referrer-Policy is set to // strict-origin-when-cross-origin. X-Request-Id ties the event to the // local access log line, which holds the rest of the detail. func sentryKeepsHeader(name string) bool { diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 8efe77f..f031acf 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/source_logs.html b/templates/source_logs.html index d18ae87..b1fce27 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -8,14 +8,15 @@ ← Back to {{.Webhook.Name}}

Full Event Log

- {{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}} + {{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}
- {{range .Events}} -
+ + {{range $i, $event := .Events}} +
@@ -51,7 +52,6 @@
-
@@ -79,7 +79,6 @@ {{if and .Status.Terminal (not .Target.Deleted)}}
-
{{end}} @@ -100,18 +99,5 @@ {{end}}
- - - {{if or .HasPrev .HasNext}} -
- {{if .HasPrev}} - ← Previous - {{end}} - Page {{.Page}} of {{.TotalPages}} - {{if .HasNext}} - Next → - {{end}} -
- {{end}}
{{end}}