From 63ee6c2909d8aeefa51534d767bd1a39dcc4aac3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 09:08:08 -0500 Subject: [PATCH] fix(webhooks): filtered JSON drill links on the health tile and home KPIs The webhooksHealth tile linked with a base64 filter, unlike every other qbit (url-encoded JSON). Next 1.0.0-RC.35 deserializeFilter accepts both, but use the common JSON form. The home KPI tiles all linked to the unfiltered delivery table; they now open Deliveries (or Attempts) with the matching filter (delivered in 24 h, failed attempts, dead letters, waiting). Co-Authored-By: Claude Opus 5.5 --- .../qbits/webhooks/app/WebhooksHomeView.java | 23 ++++++-- .../webhooks/health/WebhooksHealthWidget.java | 4 +- .../qbits/webhooks/support/FilterLinks.java | 59 +++++++++++++++++++ .../qbits/webhooks/app/WebhooksHomeTest.java | 33 +++++++++++ .../health/WebhooksHealthWidgetTest.java | 15 ++++- 5 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/webhooks/support/FilterLinks.java diff --git a/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java index ded12c9..3c32672 100644 --- a/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java +++ b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java @@ -22,11 +22,18 @@ package com.kingsrook.qbits.webhooks.app; import java.io.Serializable; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Locale; +import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.AttemptOutcome; +import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus; import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; +import com.kingsrook.qbits.webhooks.support.FilterLinks; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -117,10 +124,18 @@ public final class WebhooksHomeView private static void kpis(StringBuilder html, WebhooksHomeData data, String prefix) { html.append("
"); - kpi(html, String.valueOf(data.getDelivered24h()), "Delivered (24 h)", false, prefix + "/" + WebhooksTables.DELIVERY); - kpi(html, String.valueOf(data.getFailedAttempts24h()), "Failed attempts (24 h)", data.getFailedAttempts24h() > 0, prefix + "/" + WebhooksTables.DELIVERY); - kpi(html, String.valueOf(data.getDeadLetters()), "Dead letters", data.getDeadLetters() > 0, prefix + "/" + WebhooksTables.DELIVERY); - kpi(html, String.valueOf(data.getBacklog()), "Waiting to send", false, prefix + "/" + WebhooksTables.DELIVERY); + String delivery = prefix + "/" + WebhooksTables.DELIVERY; + String since = data.getNow().minus(Duration.ofHours(24)).toString(); + kpi(html, String.valueOf(data.getDelivered24h()), "Delivered (24 h)", false, + FilterLinks.href(delivery, new QQueryFilter(new QFilterCriteria("deliveredDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since)))); + kpi(html, String.valueOf(data.getFailedAttempts24h()), "Failed attempts (24 h)", data.getFailedAttempts24h() > 0, + FilterLinks.href(prefix + "/" + WebhooksTables.ATTEMPT, new QQueryFilter(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since), + new QFilterCriteria("outcome", QCriteriaOperator.NOT_IN, AttemptOutcome.SUCCEEDED.name(), AttemptOutcome.RATE_LIMITED.name(), AttemptOutcome.IN_FLIGHT.name())))); + kpi(html, String.valueOf(data.getDeadLetters()), "Dead letters", data.getDeadLetters() > 0, + FilterLinks.href(delivery, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, DeliveryStatus.DEAD_LETTER.name())))); + kpi(html, String.valueOf(data.getBacklog()), "Waiting to send", false, + FilterLinks.href(delivery, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, + DeliveryStatus.PENDING.name(), DeliveryStatus.RETRY_WAIT.name(), DeliveryStatus.SENDING.name())))); kpi(html, data.getP50Millis() == null ? "None yet" : data.getP50Millis() + " / " + data.getP95Millis() + " ms", "Latency, median / 95th (24 h)", false, null); html.append("
"); } diff --git a/src/main/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidget.java b/src/main/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidget.java index 5979ed4..8ce35b9 100644 --- a/src/main/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidget.java +++ b/src/main/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidget.java @@ -23,7 +23,7 @@ package com.kingsrook.qbits.webhooks.health; import java.util.List; import java.util.Set; -import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; +import com.kingsrook.qbits.webhooks.support.FilterLinks; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; @@ -64,7 +64,7 @@ public class WebhooksHealthWidget extends AbstractWidgetRenderer } } return (new RenderWidgetOutput(new StatisticsData().withCount(status).withCountContext("Webhooks - System Health") - .withCountURL(AiLinkBuilder.filteredTableHref(HealthMetaDataProvider.CHECK_TABLE, + .withCountURL(FilterLinks.href("/app/" + HealthMetaDataProvider.CHECK_TABLE, new QQueryFilter(new QFilterCriteria("component", QCriteriaOperator.EQUALS, "webhooks")))))); } } diff --git a/src/main/java/com/kingsrook/qbits/webhooks/support/FilterLinks.java b/src/main/java/com/kingsrook/qbits/webhooks/support/FilterLinks.java new file mode 100644 index 0000000..cfcffd6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/webhooks/support/FilterLinks.java @@ -0,0 +1,59 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webhooks.support; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** Links to a filtered table, in the form every QQQ qbit uses and the Next UI + ** reads: {@code ?filter=}. + *******************************************************************************/ +public final class FilterLinks +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private FilterLinks() + { + } + + + + /******************************************************************************* + ** The path (a table path, with any prefix), with the filter as URL-encoded JSON. + *******************************************************************************/ + public static String href(String path, QQueryFilter filter) + { + Map link = new LinkedHashMap<>(); + link.put("criteria", filter.getCriteria()); + link.put("orderBys", filter.getOrderBys()); + link.put("subFilters", filter.getSubFilters()); + link.put("booleanOperator", filter.getBooleanOperator()); + return (path + "?filter=" + URLEncoder.encode(JsonUtils.toJson(link), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java index 196ca50..6665e9c 100644 --- a/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java +++ b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java @@ -21,7 +21,12 @@ package com.kingsrook.qbits.webhooks.app; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import com.kingsrook.qbits.webhooks.WebhooksTestSupport; import com.kingsrook.qbits.webhooks.WebhooksTestSupport.Receiver; import com.kingsrook.qbits.webhooks.delivery.DeliveryEngine; @@ -29,11 +34,14 @@ import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissions; import com.kingsrook.qbits.webhooks.support.SystemWrites; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -133,6 +141,31 @@ class WebhooksHomeTest + /******************************************************************************* + ** The KPI drill links open the delivery or attempt table with the matching + ** filter, as URL-encoded JSON. + *******************************************************************************/ + @Test + void kpiDrillLinksAreFilteredJson() throws Exception + { + String html = home(); + Matcher matcher = Pattern.compile("href=\"(/admin/webhooks/([a-z_]+)\\?filter=([^\"]+))\"").matcher(html); + Map byTable = new HashMap<>(); + int links = 0; + while(matcher.find()) + { + links++; + QQueryFilter filter = JsonUtils.toObject(URLDecoder.decode(matcher.group(3), StandardCharsets.UTF_8), QQueryFilter.class); + byTable.put(matcher.group(2) + ":" + filter.getCriteria().getFirst().getFieldName(), filter); + } + assertThat(links).isEqualTo(4); + assertThat(byTable).containsKeys(WebhooksTables.DELIVERY + ":deliveredDate", WebhooksTables.ATTEMPT + ":startDate", WebhooksTables.DELIVERY + ":status"); + assertThat(byTable.get(WebhooksTables.DELIVERY + ":status").getCriteria()).hasSize(1); + assertThat(byTable.get(WebhooksTables.ATTEMPT + ":startDate").getCriteria().get(1).getOperator()).isEqualTo(QCriteriaOperator.NOT_IN); + } + + + /******************************************************************************* ** The secret value: admins only, behind a toggle; references not shown. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidgetTest.java b/src/test/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidgetTest.java index 60932dc..48d8348 100644 --- a/src/test/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidgetTest.java +++ b/src/test/java/com/kingsrook/qbits/webhooks/health/WebhooksHealthWidgetTest.java @@ -23,18 +23,20 @@ package com.kingsrook.qbits.webhooks.health; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.List; import com.kingsrook.qbits.webhooks.WebhooksTestSupport; import com.kingsrook.qbits.webhooks.app.WebhooksApp; import com.kingsrook.qbits.webhooks.support.SystemWrites; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -55,8 +57,15 @@ class WebhooksHealthWidgetTest StatisticsData data = render(); assertThat(data.getCount()).isEqualTo("No checks have run yet"); assertThat(data.getCountURL()).startsWith("/app/health_check?filter="); - String filter = new String(Base64.getDecoder().decode(URLDecoder.decode(data.getCountURL().split("filter=")[1], StandardCharsets.UTF_8)), StandardCharsets.UTF_8); - assertThat(filter).contains("component", "webhooks"); + /////////////////////////////////////////////////////////////////////////////// + // plain URL-encoded JSON (the form every qbit uses), not base64 // + /////////////////////////////////////////////////////////////////////////////// + String filter = URLDecoder.decode(data.getCountURL().split("filter=")[1], StandardCharsets.UTF_8); + QQueryFilter decoded = JsonUtils.toObject(filter, QQueryFilter.class); + assertThat(decoded.getCriteria()).hasSize(1); + assertThat(decoded.getCriteria().getFirst().getFieldName()).isEqualTo("component"); + assertThat(decoded.getCriteria().getFirst().getOperator()).isEqualTo(QCriteriaOperator.EQUALS); + assertThat(decoded.getCriteria().getFirst().getValues()).containsExactly("webhooks"); new HealthMetaDataProvider().defineAll(instance, WebhooksTestSupport.MEMORY, null); QContext.init(instance, new QSession().withPermissions(List.of("health.read"))); assertThat(render().getCount()).isEqualTo("No checks have run yet"); -- 2.54.0