fix(ui): Next UI-safe home, full-width relations, read-only deliveries
Checked against Next UI 1.0.0-RC.14 with a local demo server (H2, real traffic from the engine): Next's sanitizer removes <style>, so the home now uses inline styles only, with intrinsic auto-fit grids instead of media queries; the relation widgets and the payload span the full record width; deliveries and attempts drop the insert, update and delete capabilities so the UI offers no Edit or New (they are written only by the qbit); the home widget is labelled Overview. Screenshots at 390 and 1440 px, light and dark: no page or inner horizontal scroll. Adds the test-scoped WebhooksDemoServer used for the screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0814ac59b7
commit
f6ecc75d7f
7 files changed
+396
-89
No files matched your search
@@ -96,6 +96,17 @@
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
|
||||
@@ -77,7 +77,7 @@ public final class WebhooksApp
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Webhooks").withType(WidgetType.HTML.getType())
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(WebhooksTables.permissionRules()),
|
||||
new QWidgetMetaData().withName(SECRET_VALUE_WIDGET).withLabel("Secret Value").withType(WidgetType.HTML.getType())
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
package com.kingsrook.qbits.webhooks.app;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
@@ -33,51 +35,38 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
/*******************************************************************************
|
||||
** Renders the Webhooks home as HTML for an HTML widget.
|
||||
**
|
||||
** <p>Layout rules (owner, 2026-10-11): one page scroll (nothing scrolls
|
||||
** inside the widget), no horizontal scroll at any width, tables full width
|
||||
** that become stacked cards on narrow screens, dates compact on one line,
|
||||
** and plain labels. Colours come from the current text colour with opacity,
|
||||
** plus green and red that read on light and dark backgrounds.</p>
|
||||
** <p>Next UI's sanitizer removes {@code <style>} elements, so everything is
|
||||
** inline styles. Layout rules (owner, 2026-10-11):</p>
|
||||
** <ul>
|
||||
** <li>one page scroll: nothing scrolls inside the widget;</li>
|
||||
** <li>no horizontal scroll at any width;</li>
|
||||
** <li>list rows are cards whose labelled cells wrap by themselves
|
||||
** ({@code auto-fit} grids with {@code min(100%, ...)}), so they read
|
||||
** at 360 px and at 3200 px without media queries;</li>
|
||||
** <li>dates compact and on one line, plain labels;</li>
|
||||
** <li>colours from the current text colour with opacity, plus a green and
|
||||
** a red that read on light and dark backgrounds.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksHomeView
|
||||
{
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
|
||||
|
||||
private static final String OK_COLOR = "#2e9e5b";
|
||||
private static final String FAIL_COLOR = "#d64545";
|
||||
|
||||
private static final String STYLE = """
|
||||
<style>
|
||||
.wh-home{display:flex;flex-direction:column;gap:1rem;min-width:0;font-size:.95rem;line-height:1.4}
|
||||
.wh-home *{box-sizing:border-box}
|
||||
.wh-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,10.5rem),1fr));gap:.75rem}
|
||||
.wh-kpi{border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:.75rem 1rem;min-width:0}
|
||||
.wh-kpi .v{font-size:1.6rem;font-weight:600;line-height:1.2}
|
||||
.wh-kpi .l{opacity:.75;font-size:.85rem}
|
||||
.wh-kpi.bad .v{color:%FAIL%}
|
||||
.wh-card{border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0}
|
||||
.wh-card h3{margin:0 0 .75rem;font-size:1rem;font-weight:600}
|
||||
.wh-muted{opacity:.75}
|
||||
.wh-bars{display:flex;align-items:flex-end;gap:2px;height:7rem;width:100%}
|
||||
.wh-bar{flex:1 1 0;min-width:0;display:flex;flex-direction:column-reverse;height:100%}
|
||||
.wh-bar .ok{background:%OK%;opacity:.85}
|
||||
.wh-bar .no{background:%FAIL%;opacity:.9}
|
||||
.wh-axis{display:flex;justify-content:space-between;font-size:.75rem;opacity:.7;margin-top:.25rem}
|
||||
.wh-legend{display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;margin-top:.5rem}
|
||||
.wh-legend i{display:inline-block;width:.7rem;height:.7rem;border-radius:2px;margin-right:.3rem;vertical-align:-1px}
|
||||
.wh-table{width:100%;border-collapse:collapse}
|
||||
.wh-table th{text-align:left;font-weight:600;font-size:.8rem;opacity:.75;padding:.35rem .5rem;border-bottom:1px solid rgba(127,127,127,.3)}
|
||||
.wh-table td{padding:.45rem .5rem;border-bottom:1px solid rgba(127,127,127,.15);vertical-align:top;overflow-wrap:anywhere}
|
||||
.wh-table td.nw{white-space:nowrap}
|
||||
@media (max-width:640px){
|
||||
.wh-table thead{display:none}
|
||||
.wh-table tr{display:block;border:1px solid rgba(127,127,127,.25);border-radius:6px;margin-bottom:.5rem;padding:.25rem .5rem}
|
||||
.wh-table td{display:flex;gap:.5rem;border:0;padding:.2rem 0}
|
||||
.wh-table td::before{content:attr(data-label);font-weight:600;opacity:.7;min-width:6.5rem;flex:0 0 auto}
|
||||
.wh-table td.nw{white-space:normal}
|
||||
}
|
||||
</style>
|
||||
""".replace("%OK%", OK_COLOR).replace("%FAIL%", FAIL_COLOR);
|
||||
private static final String HOME = "display:flex;flex-direction:column;gap:1rem;min-width:0;font-size:.95rem;line-height:1.4;";
|
||||
private static final String KPIS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9.5rem),1fr));gap:.75rem;";
|
||||
private static final String KPI = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:.75rem 1rem;min-width:0;";
|
||||
private static final String VALUE = "font-size:1.6rem;font-weight:600;line-height:1.2;overflow-wrap:anywhere;";
|
||||
private static final String LABEL = "opacity:.75;font-size:.85rem;";
|
||||
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;";
|
||||
private static final String TITLE = "margin:0 0 .75rem;font-size:1rem;font-weight:600;";
|
||||
private static final String MUTED = "opacity:.75;margin:0;";
|
||||
private static final String ROW = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr));gap:.25rem 1rem;padding:.6rem 0;"
|
||||
+ "border-top:1px solid rgba(127,127,127,.2);align-items:start;";
|
||||
private static final String CELL = "min-width:0;overflow-wrap:anywhere;";
|
||||
private static final String CAP = "display:block;font-size:.75rem;font-weight:600;opacity:.65;";
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +84,7 @@ public final class WebhooksHomeView
|
||||
*******************************************************************************/
|
||||
public static String render(WebhooksHomeData data, String tablePrefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder(STYLE).append("<div class=\"wh-home\">");
|
||||
StringBuilder html = new StringBuilder("<div style=\"" + HOME + "\">");
|
||||
kpis(html, data, tablePrefix);
|
||||
volume(html, data);
|
||||
attention(html, data, tablePrefix);
|
||||
@@ -106,11 +95,11 @@ public final class WebhooksHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The KPI row.
|
||||
** The KPI tiles.
|
||||
*******************************************************************************/
|
||||
private static void kpis(StringBuilder html, WebhooksHomeData data, String prefix)
|
||||
{
|
||||
html.append("<div class=\"wh-kpis\">");
|
||||
html.append("<div style=\"").append(KPIS).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);
|
||||
@@ -127,7 +116,8 @@ public final class WebhooksHomeView
|
||||
*******************************************************************************/
|
||||
private static void kpi(StringBuilder html, String value, String label, boolean bad, String href)
|
||||
{
|
||||
html.append("<div class=\"wh-kpi").append(bad ? " bad" : "").append("\"><div class=\"v\">");
|
||||
String valueStyle = VALUE + (bad ? "color:" + FAIL_COLOR + ";" : "");
|
||||
html.append("<div style=\"").append(KPI).append("\"><div style=\"").append(valueStyle).append("\">");
|
||||
if(href != null)
|
||||
{
|
||||
html.append("<a href=\"").append(esc(href)).append("\" style=\"color:inherit;text-decoration:none\">").append(esc(value)).append("</a>");
|
||||
@@ -136,7 +126,7 @@ public final class WebhooksHomeView
|
||||
{
|
||||
html.append(esc(value));
|
||||
}
|
||||
html.append("</div><div class=\"l\">").append(esc(label)).append("</div></div>");
|
||||
html.append("</div><div style=\"").append(LABEL).append("\">").append(esc(label)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
@@ -155,58 +145,69 @@ public final class WebhooksHomeView
|
||||
max = Math.max(max, ok[i] + failed[i]);
|
||||
total += ok[i] + failed[i];
|
||||
}
|
||||
html.append("<div class=\"wh-card\"><h3>Delivery attempts, last 24 hours</h3>");
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Delivery attempts, last 24 hours</h3>");
|
||||
if(total == 0)
|
||||
{
|
||||
html.append("<p class=\"wh-muted\">No delivery attempts in the last 24 hours.</p></div>");
|
||||
html.append("<p style=\"").append(MUTED).append("\">No delivery attempts in the last 24 hours.</p></div>");
|
||||
return;
|
||||
}
|
||||
html.append("<div class=\"wh-bars\" role=\"img\" aria-label=\"").append(total).append(" attempts in the last 24 hours, by hour\">");
|
||||
html.append("<div role=\"img\" aria-label=\"").append(total).append(" attempts in the last 24 hours, by hour\" style=\"display:flex;align-items:flex-end;gap:2px;height:7rem;width:100%\">");
|
||||
for(int i = 0; i < 24; i++)
|
||||
{
|
||||
double okPct = 100.0 * ok[i] / max;
|
||||
double failPct = 100.0 * failed[i] / max;
|
||||
html.append("<div class=\"wh-bar\" title=\"").append(ok[i]).append(" succeeded, ").append(failed[i]).append(" failed\">")
|
||||
.append("<div class=\"ok\" style=\"height:").append(String.format(java.util.Locale.ROOT, "%.1f", okPct)).append("%\"></div>")
|
||||
.append("<div class=\"no\" style=\"height:").append(String.format(java.util.Locale.ROOT, "%.1f", failPct)).append("%\"></div></div>");
|
||||
html.append("<div title=\"").append(ok[i]).append(" succeeded, ").append(failed[i]).append(" failed\" style=\"flex:1 1 0;min-width:0;height:100%;display:flex;flex-direction:column-reverse\">")
|
||||
.append("<div style=\"background:").append(OK_COLOR).append(";height:").append(pct(ok[i], max)).append("%\"></div>")
|
||||
.append("<div style=\"background:").append(FAIL_COLOR).append(";height:").append(pct(failed[i], max)).append("%\"></div></div>");
|
||||
}
|
||||
html.append("</div><div class=\"wh-axis\"><span>24 h ago</span><span>now</span></div>")
|
||||
.append("<div class=\"wh-legend\"><span><i style=\"background:").append(OK_COLOR).append("\"></i>Succeeded</span>")
|
||||
.append("<span><i style=\"background:").append(FAIL_COLOR).append("\"></i>Failed</span>")
|
||||
.append("<span class=\"wh-muted\">Peak ").append(max).append(" per hour; ").append(data.getRateLimited24h()).append(" rate-limited</span></div></div>");
|
||||
html.append("</div><div style=\"display:flex;justify-content:space-between;font-size:.75rem;opacity:.7;margin-top:.25rem\"><span>24 h ago</span><span>Now</span></div>")
|
||||
.append("<div style=\"display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;margin-top:.5rem\">")
|
||||
.append(legend(OK_COLOR, "Succeeded")).append(legend(FAIL_COLOR, "Failed"))
|
||||
.append("<span style=\"opacity:.75\">Peak ").append(max).append(" per hour; ").append(data.getRateLimited24h()).append(" rate-limited</span></div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Endpoints needing attention (full width table, cards when narrow).
|
||||
** A legend entry.
|
||||
*******************************************************************************/
|
||||
private static String legend(String color, String label)
|
||||
{
|
||||
return ("<span><span style=\"display:inline-block;width:.7rem;height:.7rem;border-radius:2px;margin-right:.3rem;vertical-align:-1px;background:" + color + "\"></span>"
|
||||
+ esc(label) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Endpoints needing attention, one card row each.
|
||||
*******************************************************************************/
|
||||
private static void attention(StringBuilder html, WebhooksHomeData data, String prefix)
|
||||
{
|
||||
html.append("<div class=\"wh-card\"><h3>Endpoints needing attention</h3>");
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Endpoints needing attention</h3>");
|
||||
if(data.getEndpointCount() == 0)
|
||||
{
|
||||
html.append("<p class=\"wh-muted\">No endpoints yet. <a href=\"").append(esc(prefix + "/" + WebhooksTables.ENDPOINT)).append("\">Add an endpoint</a>, then subscribe it to events.</p></div>");
|
||||
html.append("<p style=\"").append(MUTED).append("\">No endpoints yet. <a href=\"").append(esc(prefix + "/" + WebhooksTables.ENDPOINT))
|
||||
.append("\">Add an endpoint</a>, then subscribe it to events.</p></div>");
|
||||
return;
|
||||
}
|
||||
if(data.getAttention().isEmpty())
|
||||
{
|
||||
html.append("<p class=\"wh-muted\">All ").append(data.getEndpointCount()).append(" endpoints are healthy.</p></div>");
|
||||
html.append("<p style=\"").append(MUTED).append("\">All ").append(data.getEndpointCount()).append(" endpoints are healthy.</p></div>");
|
||||
return;
|
||||
}
|
||||
html.append("<table class=\"wh-table\"><thead><tr><th>Endpoint</th><th>Why</th><th>Last failure</th><th>Last success</th></tr></thead><tbody>");
|
||||
List<QRecord> rows = data.getAttention();
|
||||
for(int i = 0; i < rows.size(); i++)
|
||||
{
|
||||
QRecord endpoint = rows.get(i);
|
||||
html.append("<tr><td data-label=\"Endpoint\"><a href=\"").append(esc(prefix + "/" + WebhooksTables.ENDPOINT + "/" + endpoint.getValue("id"))).append("\">")
|
||||
.append(esc(endpoint.getValueString("name"))).append("</a></td>")
|
||||
.append("<td data-label=\"Why\">").append(esc(data.getAttentionWhy().get(i)))
|
||||
.append(endpoint.getValueString("lastFailureMessage") == null ? "" : "<br><span class=\"wh-muted\">" + esc(clamp(endpoint.getValueString("lastFailureMessage"), 140)) + "</span>")
|
||||
.append("</td><td data-label=\"Last failure\" class=\"nw\">").append(when(endpoint.getValue("lastFailureDate")))
|
||||
.append("</td><td data-label=\"Last success\" class=\"nw\">").append(when(endpoint.getValue("lastSuccessDate"))).append("</td></tr>");
|
||||
String message = endpoint.getValueString("lastFailureMessage");
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Endpoint", "<a href=\"" + esc(prefix + "/" + WebhooksTables.ENDPOINT + "/" + endpoint.getValue("id")) + "\">"
|
||||
+ esc(endpoint.getValueString("name")) + "</a>"))
|
||||
.append(cell("Why", esc(data.getAttentionWhy().get(i)) + (message == null ? "" : "<br><span style=\"opacity:.75\">" + esc(clamp(message, 140)) + "</span>")))
|
||||
.append(cell("Last failure", when(endpoint.getValue("lastFailureDate"))))
|
||||
.append(cell("Last success", when(endpoint.getValue("lastSuccessDate"))))
|
||||
.append("</div>");
|
||||
}
|
||||
html.append("</tbody></table></div>");
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
@@ -216,33 +217,54 @@ public final class WebhooksHomeView
|
||||
*******************************************************************************/
|
||||
private static void deadLetters(StringBuilder html, WebhooksHomeData data, String prefix)
|
||||
{
|
||||
html.append("<div class=\"wh-card\"><h3>Newest dead letters</h3>");
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Newest dead letters</h3>");
|
||||
if(data.getRecentDeadLetters().isEmpty())
|
||||
{
|
||||
html.append("<p class=\"wh-muted\">No dead letters.</p></div>");
|
||||
html.append("<p style=\"").append(MUTED).append("\">No dead letters.</p></div>");
|
||||
return;
|
||||
}
|
||||
html.append("<table class=\"wh-table\"><thead><tr><th>Delivery</th><th>Event type</th><th>Last error</th><th>Dead-lettered</th></tr></thead><tbody>");
|
||||
for(QRecord delivery : data.getRecentDeadLetters())
|
||||
{
|
||||
html.append("<tr><td data-label=\"Delivery\"><a href=\"").append(esc(prefix + "/" + WebhooksTables.DELIVERY + "/" + delivery.getValue("id"))).append("\">#")
|
||||
.append(esc(String.valueOf(delivery.getValue("id")))).append("</a></td>")
|
||||
.append("<td data-label=\"Event type\">").append(esc(delivery.getValueString("eventTypeName"))).append("</td>")
|
||||
.append("<td data-label=\"Last error\">").append(esc(clamp(delivery.getValueString("lastError"), 140))).append("</td>")
|
||||
.append("<td data-label=\"Dead-lettered\" class=\"nw\">").append(when(delivery.getValue("deadLetterDate"))).append("</td></tr>");
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Delivery", "<a href=\"" + esc(prefix + "/" + WebhooksTables.DELIVERY + "/" + delivery.getValue("id")) + "\">#"
|
||||
+ esc(String.valueOf(delivery.getValue("id"))) + "</a>"))
|
||||
.append(cell("Event type", esc(delivery.getValueString("eventTypeName"))))
|
||||
.append(cell("Last error", esc(clamp(delivery.getValueString("lastError"), 140))))
|
||||
.append(cell("Dead-lettered", when(delivery.getValue("deadLetterDate"))))
|
||||
.append("</div>");
|
||||
}
|
||||
html.append("</tbody></table><p class=\"wh-muted\">Select dead letters in Deliveries and run Replay to send them again.</p></div>");
|
||||
html.append("<p style=\"").append(MUTED).append("margin-top:.5rem\">Select dead letters in Deliveries and run Replay to send them again.</p></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A compact date, or a dash.
|
||||
** A labelled cell.
|
||||
*******************************************************************************/
|
||||
private static String cell(String label, String valueHtml)
|
||||
{
|
||||
return ("<div style=\"" + CELL + "\"><span style=\"" + CAP + "\">" + esc(label) + "</span>" + valueHtml + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Percent of max, one decimal.
|
||||
*******************************************************************************/
|
||||
private static String pct(int value, int max)
|
||||
{
|
||||
return (String.format(Locale.ROOT, "%.1f", max == 0 ? 0.0 : 100.0 * value / max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A compact date (never wrapped), or a dash.
|
||||
*******************************************************************************/
|
||||
private static String when(Object value)
|
||||
{
|
||||
Instant instant = value == null ? null : ValueUtils.getValueAsInstant((java.io.Serializable) value);
|
||||
return (instant == null ? "-" : esc(WHEN.format(instant)));
|
||||
Instant instant = value == null ? null : ValueUtils.getValueAsInstant((Serializable) value);
|
||||
return (instant == null ? "-" : "<span style=\"white-space:nowrap\">" + esc(WHEN.format(instant)) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -160,7 +161,19 @@ public final class WebhooksTables
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(WebhooksQBitConfig config)
|
||||
{
|
||||
return (List.of(endpoint(config), secret(config), subscription(config), delivery(config), attempt(config)));
|
||||
List<QTableMetaData> tables = List.of(endpoint(config), secret(config), subscription(config), delivery(config), attempt(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
if(SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// no New / Edit / Delete in the UI or API: people act on these //
|
||||
// through the actions; the qbit writes them itself //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
}
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,27 +202,27 @@ public final class WebhooksTables
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_SUBSCRIPTION))
|
||||
.withName(WIDGET_ENDPOINT_SUBSCRIPTIONS).withLabel("Subscriptions").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withDisabledFieldsForNewChildRecords(Set.of("endpointId"))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_SECRET))
|
||||
.withName(WIDGET_ENDPOINT_SECRETS).withLabel("Signing Secrets").withMaxRows(20)
|
||||
.withOmitFieldNames(List.of("encryptedSecret", "endpointId"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_DELIVERY))
|
||||
.withName(WIDGET_ENDPOINT_DELIVERIES).withLabel("Recent Deliveries").withMaxRows(25)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "eventTypeName", "status", "attemptCount", "lastHttpStatus", "occurredDate", "deliveredDate"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_SUBSCRIPTION_DELIVERY))
|
||||
.withName(WIDGET_SUBSCRIPTION_DELIVERIES).withLabel("Recent Deliveries").withMaxRows(25)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "status", "attemptCount", "lastHttpStatus", "occurredDate", "deliveredDate"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_DELIVERY_ATTEMPT))
|
||||
.withName(WIDGET_DELIVERY_ATTEMPTS).withLabel("Attempts").withMaxRows(50)
|
||||
.withOnlyIncludeFieldNames(List.of("attemptNumber", "outcome", "httpStatus", "durationMillis", "startDate", "errorMessage"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("attemptNumber", true)))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
@@ -459,7 +472,7 @@ public final class WebhooksTables
|
||||
.withSection(SectionFactory.customT2("source", new QIcon("input"), "eventId", "source", "sourceTable", "sourceRecordId", "sourceEventId",
|
||||
"subscriptionId", "dedupKey", "leaseExpiresDate", "payloadBytes"))
|
||||
.withSection(SectionFactory.customT2("attempts", new QIcon("history")).withWidgetName(WIDGET_DELIVERY_ATTEMPTS))
|
||||
.withSection(SectionFactory.customT2("payloadSection", new QIcon("data_object"), "payload").withLabel("Payload"))
|
||||
.withSection(SectionFactory.customT2("payloadSection", new QIcon("data_object"), "payload").withLabel("Payload").withGridColumns(12))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Endpoint").withJoinTable(ENDPOINT).withJoinPath(List.of(JOIN_ENDPOINT_DELIVERY)));
|
||||
return (table);
|
||||
|
||||
@@ -65,6 +65,13 @@ class WebhooksQBitProducerTest
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).isEqualTo("webhooks");
|
||||
}
|
||||
assertThat(instance.getTable(WebhooksTables.DELIVERY).getField("nextAttemptDate").getBackendName()).isEqualTo("next_attempt_date");
|
||||
for(String systemWritten : WebhooksTables.SYSTEM_WRITTEN)
|
||||
{
|
||||
assertThat(instance.getTable(systemWritten).getDisabledCapabilities()).contains(
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE,
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -125,12 +125,12 @@ class WebhooksHomeTest
|
||||
assertThat(data.getP50Millis()).isNotNull();
|
||||
|
||||
String html = home();
|
||||
assertThat(html).contains("Delivered (24 h)").contains("wh-bars").contains("bad <b>").doesNotContain("bad <b>")
|
||||
assertThat(html).contains("Delivered (24 h)").contains("attempts in the last 24 hours, by hour").contains("bad <b>").doesNotContain("bad <b>")
|
||||
.contains("/admin/webhooks/webhooks_delivery/" + deadOne.getValue("id")).contains("Replay");
|
||||
////////////////////////////////////////////////////////////////
|
||||
// readability rules: no fixed widths that force a scroll bar //
|
||||
////////////////////////////////////////////////////////////////
|
||||
assertThat(html).doesNotContain("overflow-x").doesNotContain("overflow:auto").contains("@media (max-width:640px)");
|
||||
assertThat(html).doesNotContain("overflow-x").doesNotContain("overflow:auto").doesNotContain("<style").contains("minmax(min(100%");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.demo;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitProducer;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksTestSupport;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksTestSupport.Receiver;
|
||||
import com.kingsrook.qbits.webhooks.delivery.DeliveryEngine;
|
||||
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.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.FullyAnonymousAuthenticationModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A local demo (for screenshots and manual checks, never shipped): H2, a
|
||||
** cms_blog_post table, the webhooks qbit, Next UI on the given port, and 24
|
||||
** hours of real traffic made by the real engine against local receivers
|
||||
** (one healthy, one failing, one rate-limiting, one internal).
|
||||
**
|
||||
** <pre>
|
||||
** ./mvnw -o test-compile exec:java -Dexec.classpathScope=test \
|
||||
** -Dexec.mainClass=com.kingsrook.qbits.webhooks.demo.WebhooksDemoServer -Dexec.args=8099
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
public class WebhooksDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
private static final String BLOG = "cms_blog_post";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Anonymous, with every webhooks permission (demo only; swapped into the
|
||||
** dispatcher's map by reflection, because it keeps the first module per
|
||||
** type).
|
||||
*******************************************************************************/
|
||||
public static class DemoAuthenticationModule extends FullyAnonymousAuthenticationModule
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QSession createSession(QInstance qInstance, Map<String, String> context)
|
||||
{
|
||||
QSession session = super.createSession(qInstance, context);
|
||||
session.withPermissions(WebhooksPermissions.ALL).withPermission(BLOG + ".read");
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The database.
|
||||
*******************************************************************************/
|
||||
static RDBMSBackendMetaData backend()
|
||||
{
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName("rdbms").withVendor("h2").withHostName("mem").withDatabaseName("webhooks_demo").withUsername("sa");
|
||||
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE");
|
||||
return (backend);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(backend());
|
||||
instance.addTable(new QTableMetaData().withName(BLOG).withLabel("Blog Post").withBackendName("rdbms").withPrimaryKeyField("id")
|
||||
.withRecordLabelFormatAndFields("%s", "title")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withBackendName("id"))
|
||||
.withField(new QFieldMetaData("title", QFieldType.STRING).withBackendName("title"))
|
||||
.withField(new QFieldMetaData("slug", QFieldType.STRING).withBackendName("slug"))
|
||||
.withField(new QFieldMetaData("status", QFieldType.STRING).withBackendName("status")));
|
||||
instance.addApp(new QAppMetaData().withName("cms").withLabel("CMS").withIcon(new QIcon("article")).withChild(instance.getTable(BLOG)));
|
||||
|
||||
new WebhooksQBitProducer().withQBitConfig(new WebhooksQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSourceName("demo")
|
||||
.withEncryptionKeyEnvName(WebhooksTestSupport.KEY_PROPERTY)
|
||||
.withTableEventSources(List.of(BLOG))
|
||||
.withAllowPlainHttp(true)
|
||||
.withMaxAttempts(3)
|
||||
.withRetryMinutes(List.of(1, 5))
|
||||
.withUnhealthyAfterConsecutiveFailures(5)
|
||||
.withTablePathPrefix("/app"))
|
||||
.produce(instance);
|
||||
instance.setHomeAppName("webhooks");
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create tables and 24 hours of traffic.
|
||||
*******************************************************************************/
|
||||
static void seed(QInstance instance, Receiver receiver) throws Exception
|
||||
{
|
||||
try(var connection = new ConnectionManager().getConnection(backend()))
|
||||
{
|
||||
String sql = new String(WebhooksDemoServer.class.getResourceAsStream("/webhooks-h2.sql").readAllBytes());
|
||||
StringBuilder kept = new StringBuilder();
|
||||
for(String line : sql.split("\n"))
|
||||
{
|
||||
if(!line.startsWith("--"))
|
||||
{
|
||||
kept.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
for(String statement : kept.toString().split(";"))
|
||||
{
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
QueryManager.executeUpdate(connection, statement);
|
||||
}
|
||||
}
|
||||
QueryManager.executeUpdate(connection, "CREATE TABLE cms_blog_post (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200), slug VARCHAR(200), status VARCHAR(20))");
|
||||
}
|
||||
|
||||
QContext.init(instance, new QSession().withPermissions(WebhooksPermissions.ALL).withPermission(BLOG + ".read"));
|
||||
Random random = new Random(42);
|
||||
receiver.respond(r -> switch(r.path())
|
||||
{
|
||||
case "/crm" -> Receiver.Response.of(503);
|
||||
case "/slack" -> random.nextInt(5) == 0 ? new Receiver.Response(429, Map.of("Retry-After", "60"), "slow down", 0) : Receiver.Response.of(200);
|
||||
default -> new Receiver.Response(200, Map.of(), "ok", 15 + random.nextInt(60));
|
||||
});
|
||||
|
||||
Integer search = WebhooksTestSupport.endpoint("Search index sync", receiver.url("/search"));
|
||||
Integer crm = WebhooksTestSupport.endpoint("CRM contacts", receiver.url("/crm"));
|
||||
Integer slack = WebhooksTestSupport.endpoint("Slack #content", receiver.url("/slack"));
|
||||
Integer next = WebhooksTestSupport.endpoint("Next.js revalidate", receiver.url("/revalidate"));
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, new QRecord().withValue("id", search).withValue("description", "Keeps the site search index in step with posts."));
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, new QRecord().withValue("id", slack).withValue("maxPerMinute", 30));
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, new QRecord().withValue("id", next).withValue("serialDelivery", true).withValue("timeoutSeconds", 5));
|
||||
for(Integer endpoint : List.of(search, next))
|
||||
{
|
||||
for(String type : List.of("inserted", "updated", "deleted"))
|
||||
{
|
||||
WebhooksTestSupport.subscribe(endpoint, BLOG + "." + type, s -> { });
|
||||
}
|
||||
}
|
||||
WebhooksTestSupport.subscribe(slack, BLOG + ".inserted", s -> s.withValue("detailLevel", "KEYS_ONLY").withValue("description", "New posts only"));
|
||||
WebhooksTestSupport.subscribe(crm, BLOG + ".updated", s -> s.withValue("excludeFields", "slug"));
|
||||
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MINUTES);
|
||||
int n = 0;
|
||||
for(int hour = 23; hour >= 0; hour--)
|
||||
{
|
||||
WebhooksTestSupport.CLOCK.set(now.minus(Duration.ofHours(hour)).minus(Duration.ofMinutes(random.nextInt(50))));
|
||||
int posts = 2 + random.nextInt(hour % 6 == 0 ? 8 : 4);
|
||||
for(int i = 0; i < posts; i++)
|
||||
{
|
||||
n++;
|
||||
QRecord post = WebhooksTestSupport.insert(BLOG, new QRecord().withValue("title", "Post " + n).withValue("slug", "post-" + n).withValue("status", "draft"));
|
||||
if(random.nextBoolean())
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(BLOG).withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("status", "published")));
|
||||
}
|
||||
if(random.nextInt(12) == 0)
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput(BLOG).withPrimaryKeys(List.of(post.getValue("id"))));
|
||||
}
|
||||
}
|
||||
for(int run = 0; run < 3; run++)
|
||||
{
|
||||
new DeliveryEngine().runOnce();
|
||||
WebhooksTestSupport.CLOCK.advance(Duration.ofMinutes(6));
|
||||
}
|
||||
}
|
||||
WebhooksRuntime.setClock(java.time.Clock.systemUTC());
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run.
|
||||
*******************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
int port = args.length > 0 ? Integer.parseInt(args[0]) : 8099;
|
||||
System.setProperty(WebhooksTestSupport.KEY_PROPERTY, java.util.Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes()));
|
||||
java.lang.reflect.Field modules = QAuthenticationModuleDispatcher.class.getDeclaredField("authenticationTypeToModuleClassNameMap");
|
||||
modules.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> map = (Map<String, String>) modules.get(null);
|
||||
map.put(QAuthenticationType.FULLY_ANONYMOUS.getName(), DemoAuthenticationModule.class.getName());
|
||||
WebhooksRuntime.setClock(WebhooksTestSupport.CLOCK);
|
||||
|
||||
WebhooksDemoServer application = new WebhooksDemoServer();
|
||||
Receiver receiver = new Receiver();
|
||||
QInstance seedInstance = application.defineQInstance();
|
||||
new com.kingsrook.qqq.backend.core.instances.QInstanceValidator().validate(seedInstance);
|
||||
seed(seedInstance, receiver);
|
||||
|
||||
new QApplicationJavalinServer(application).withPort(port).withServeFrontendNext(true).start();
|
||||
System.out.println("Webhooks demo on http://localhost:" + port + "/app/webhooks");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user