refactor(alerts): core QAlerts and WebhookSigner on QQQ RC.5 (#15 part 1) #17

Merged
KofTwentyTwo merged 1 commits from feature/arch-rc5-standins into develop 2026-10-14 00:36:44 +00:00
55 changed files with 335 additions and 2198 deletions

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core SPI stand-in in `spi/`.
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the notification compatibility types in `spi/`.
## Build Commands
@@ -25,7 +25,7 @@ Packages:
- `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps.
- `home`: the Alerts home and `AlertSummaryLoader`.
- `api`: the bell and record-panel routes.
- `spi`: the stand-in for the core producer SPI.
- `spi`: notification types and their AlertLink dependency retained for binary compatibility until core RC.6; alerts use core RC.5.
## Naming (owner rule)
@@ -44,7 +44,7 @@ Packages:
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+7 -2
View File
@@ -4,11 +4,16 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Changed
- Build against the released QQQ 4.1.0-RC.5 BOM. Alert types and sink registration now use core's alerts SPI; host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
- Removed alert SPI copies. Retained `spi.NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, and the binary-compatible `AlertsRuntime.sink().notify(...)` entry point for qbit-easypost-tracking 0.1.x until QRun-IO/qqq#1125 ships in RC.6.
- Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding.
### Added
- Optional external channels:
- Slack (an Incoming Webhook, Block Kit)
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
- a generic webhook, signed with Standard Webhooks headers through a stand-in for core's `WebhookSigner` (QRun-IO/qqq#1109)
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
@@ -43,5 +48,5 @@ All notable changes to this project are documented here. The format follows Keep
- The top-level Alerts app and its home dashboard, plus `AlertSummaryLoader` for link-only summaries.
- `/qqq/v1/alerts` routes for the Next UI bell and record panel.
- Record security, through three key types (`alerts_user`, `alerts_permission`, `alerts_readable_table`).
- A stand-in for the core producer SPI (QRun-IO/qqq#1108), to be removed when that RC ships.
- Core alert producer SPI integration (QRun-IO/qqq#1108), plus temporary notification SPI compatibility until RC.6 (QRun-IO/qqq#1125).
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
+7 -5
View File
@@ -51,7 +51,7 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ 4.1 (built against `4.1.0-RC.5`).
- An RDBMS backend.
- A scheduler, for monitors and email delivery.
- A messaging provider, for email.
@@ -84,16 +84,18 @@ new AlertsQBitProducer()
## Raising alerts
Until the core producer SPI ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)) is in a released QQQ RC, the SPI types are a stand-in in `com.kingsrook.qbits.alerts.spi` (see its `package-info.java`). In the meantime, call the qbit's sink directly:
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
```java
AlertsRuntime.get().sink().raise(new AlertSignal("cdn.purge.dead")
QAlerts.raise(new AlertSignal("cdn.purge.dead")
.withDedupKey("provider:" + providerId)
.withTitle("3 CDN purges failed after retries") // external-safe: no personal data
.withRecordRefs(AlertRecordRef.of("cdn_purge_request", ids)));
```
Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dependency on this qbit.
`QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink.
User notifications remain on `AlertsRuntime.get(qInstance).sink().notify(...)`: core's notification API ([QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)) is expected in RC.6 and is not in RC.5. The four `spi.Notification*` types and `spi.AlertLink` retain their binary signatures for released qbit-easypost-tracking 0.1.x. Use core `AlertLink` for alert signals and `spi.AlertLink` for notification signals until that migration.
## Endpoints (for the Next UI)
@@ -122,7 +124,7 @@ Each external channel is an `alerts_channel` row that an admin creates. Its secr
How each one sends:
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). This is the shared format from the qbit-webhooks assessment (QRun-IO/qqq#1105). The signer is a stand-in until core's `WebhookSigner` (QRun-IO/qqq#1109) is released.
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed by core's `WebhookSigner` with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Both raw UTF-8 and `whsec_` base64 secrets keep the same wire format. Core's `WebhookSecret` requires 16 key bytes; a thin adapter zero-pads legacy shorter keys to 16 bytes, which is HMAC-SHA256-equivalent and preserves their signatures. New secrets should use `WebhookSecret.generate()`.
- **PagerDuty** gets one event per alert: `trigger`, `acknowledge` or `resolve`, with `dedup_key` set to the type and key.
- **Alertmanager** alerts carry the labels `qqq_type`, `qqq_group`, `qqq_key` and `qqq_origin="qbit-alerts"`. Ingest ignores alerts with that origin, so nothing loops.
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
+1 -1
View File
@@ -3,7 +3,7 @@
The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain.
Deviations from the design, and why:
- **The core SPI is a stand-in** (`spi/`) until QRun-IO/qqq#1108 is released.
- **Alerts use core's RC.5 SPI** (QRun-IO/qqq#1108), including sink registration in core instance metadata. Only notification types and their `AlertLink` signature dependency remain in `spi/`, preserving released producers until QRun-IO/qqq#1125 ships in RC.6.
- **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API.
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
+9 -4
View File
@@ -138,8 +138,9 @@ notification admins. The home stays the only Alerts dashboard.
## 6. Producer SPI
It extends the core alerts SPI (`com.kingsrook.qqq.backend.core.alerts`) through a core PR to qqq
`develop`, for the next 4.1 RC:
The notification API below is planned in [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)
for 4.1.0-RC.6. The baseline is 4.1.0-RC.5, which ships the core alert SPI
(`com.kingsrook.qqq.backend.core.alerts`) but does **not** yet provide `QAlerts.notify`:
```java
QAlerts.notify(new NotificationSignal("Your export failed")
@@ -156,8 +157,12 @@ QAlerts.notify(new NotificationSignal("Your export failed")
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
open, and bad input is counted.
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
`AlertsRuntime.get(qInstance).sink().notify(...)`.
- Until RC.6 ships, the qbit keeps `spi.NotificationSignal`, `NotificationAudience`,
`NotificationCategory`, `NotificationSeverity` and `AlertLink`, and hosts call
`AlertsRuntime.get(qInstance).sink().notify(...)`. Their binary signatures remain unchanged for
released qbit-easypost-tracking 0.1.x. The return type of `sink()` remains `QbitAlertsSink`;
there is no local sink interface. This direct notification path queues immediately; the
transaction-aware `QAlerts.notify` guarantees above describe the planned core API.
### 6.1 Automatic sources
+2 -2
View File
@@ -158,14 +158,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -36,7 +36,7 @@ import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -27,11 +27,11 @@ import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
@@ -276,8 +276,9 @@ public class AlertsRuntime
/*******************************************************************************
** The sink producers send signals to (until the core SPI ships, hosts call
** it directly: AlertsRuntime.get().sink().raise(...)).
** The runtime sink. Alert producers use core QAlerts; notification
** producers keep calling sink().notify(spi.NotificationSignal) until RC.6.
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
*******************************************************************************/
public QbitAlertsSink sink()
{
@@ -45,13 +45,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -25,8 +25,8 @@ import java.util.Map;
import com.kingsrook.qbits.alerts.home.HomeHtml;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -38,9 +38,9 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -327,7 +327,7 @@ public class DeliveryWorker
.withTitle("Alert channel \"" + channel.getValueString("label") + "\" is failing")
.withSummary(failures + " deliveries in a row failed.")
.withDetails(result.error())
.withRecordRef(com.kingsrook.qbits.alerts.spi.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
.withRecordRef(com.kingsrook.qqq.backend.core.alerts.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
}
}
@@ -17,36 +17,25 @@
package com.kingsrook.qbits.alerts.delivery.channels;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
/*******************************************************************************
** STAND-IN for core's WebhookSigner (QRun-IO/qqq#1109, the shared signer of
** the qbit-webhooks assessment, QRun-IO/qqq#1105 D2). Same format: the
** Standard Webhooks headers webhook-id, webhook-timestamp and
** webhook-signature = "v1," + base64(HMAC-SHA256(key, id + "." + timestamp +
** "." + body)); a "whsec_" secret is base64 after the prefix, any other text
** is the raw key. Replace with com.kingsrook.qqq.backend.core.utils.webhooks
** .WebhookSigner when the QQQ RC with #1109 ships, so receivers verify Alerts
** and qbit-webhooks deliveries the same way.
** Compatibility adapter for the qbit's existing webhook secret strings.
** Core signs every request. Core requires at least 16 key bytes, whereas the
** previous signer accepted shorter keys. HMAC-SHA256 zero-pads keys to its
** 64-byte block size, so padding a short legacy key to 16 bytes preserves
** its signature exactly. New secrets should use WebhookSecret.generate().
*******************************************************************************/
public final class StandardWebhookSigner
{
public static final String HEADER_ID = "webhook-id";
public static final String HEADER_TIMESTAMP = "webhook-timestamp";
public static final String HEADER_SIGNATURE = "webhook-signature";
private static final String PREFIX = "whsec_";
/*******************************************************************************
** Signing only.
*******************************************************************************/
@@ -57,55 +46,36 @@ public final class StandardWebhookSigner
/*******************************************************************************
** The three signature headers.
** The three core Standard Webhooks headers, preserving legacy short keys.
*******************************************************************************/
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String secret)
{
long seconds = timestamp.getEpochSecond();
Map<String, String> headers = new LinkedHashMap<>();
headers.put(HEADER_ID, messageId);
headers.put(HEADER_TIMESTAMP, Long.toString(seconds));
headers.put(HEADER_SIGNATURE, "v1," + sign(messageId, seconds, body, secret));
return (headers);
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
}
/*******************************************************************************
** base64 HMAC-SHA256 over id.timestamp.body.
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
*******************************************************************************/
public static String sign(String messageId, long timestampSeconds, byte[] body, String secret)
{
if(messageId == null || messageId.isEmpty() || messageId.contains(".") || messageId.chars().anyMatch(Character::isWhitespace))
{
throw (new IllegalArgumentException("A webhook message id is required and may not contain '.' or whitespace"));
}
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(keyBytes(secret), "HmacSHA256"));
mac.update((messageId + "." + timestampSeconds + ".").getBytes(StandardCharsets.UTF_8));
mac.update(body == null ? new byte[0] : body);
return (Base64.getEncoder().encodeToString(mac.doFinal()));
}
catch(GeneralSecurityException e)
{
throw (new IllegalStateException("HMAC-SHA256 is unavailable", e));
}
}
/*******************************************************************************
** The key bytes of a secret's text form.
*******************************************************************************/
static byte[] keyBytes(String secret)
private static WebhookSecret parseSecret(String secret)
{
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
String trimmed = secret.trim();
return (trimmed.startsWith(PREFIX) ? Base64.getDecoder().decode(trimmed.substring(PREFIX.length())) : trimmed.getBytes(StandardCharsets.UTF_8));
byte[] key = trimmed.startsWith(WebhookSecret.PREFIX)
? Base64.getDecoder().decode(trimmed.substring(WebhookSecret.PREFIX.length()))
: trimmed.getBytes(StandardCharsets.UTF_8);
if(key.length == 0)
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
if(key.length < WebhookSecret.MINIMUM_KEY_BYTES)
{
return (WebhookSecret.parse(WebhookSecret.PREFIX + Base64.getEncoder().encodeToString(Arrays.copyOf(key, WebhookSecret.MINIMUM_KEY_BYTES))));
}
return (WebhookSecret.parse(trimmed));
}
}
@@ -21,7 +21,7 @@ import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -45,7 +45,7 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -27,12 +27,12 @@ import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -29,11 +29,11 @@ import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -38,10 +38,10 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -26,12 +26,12 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
@@ -19,10 +19,9 @@ package com.kingsrook.qbits.alerts.engine;
import java.time.Instant;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -77,19 +76,4 @@ public class AlertsSinkAdapter implements AlertSinkInterface
runtime.sink().heartbeat(heartbeatKey, at);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void notify(NotificationSignal signal)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.sink().notify(signal);
}
}
}
@@ -45,13 +45,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -134,10 +134,11 @@ public class QbitAlertsSink implements AlertSinkInterface
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** Queue a notification (it is written and sent by the notification
** service on the drain thread).
*******************************************************************************/
@Override
public void notify(NotificationSignal signal)
{
if(signal == null)
@@ -27,7 +27,7 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -44,17 +44,17 @@ import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
@@ -20,13 +20,13 @@ package com.kingsrook.qbits.alerts.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
@@ -1,219 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
/*******************************************************************************
** A monitor that an alert type ships with ("seeded"). An alerts
** implementation inserts it once; after that it belongs to the admin, who may
** tune or disable it. Without an implementation installed it does nothing.
**
** The definition's keys depend on the kind (e.g. HEARTBEAT takes heartbeatKey
** or processName, plus expectedEverySeconds and graceSeconds; TABLE_QUERY
** takes tableName and filter). Values must be JSON-friendly.
*******************************************************************************/
public class AlertDefaultMonitor implements Serializable
{
private String name;
private AlertMonitorKind kind;
private Map<String, Serializable> definition = new LinkedHashMap<>();
private Boolean perRecord = false;
private AlertSeverity severity;
private Integer scheduleSeconds;
private Integer forMinutes = 0;
/*******************************************************************************
** A heartbeat monitor: raise when the key has not beat within the expected
** interval plus grace.
*******************************************************************************/
public static AlertDefaultMonitor heartbeat(String name, String heartbeatKey, int expectedEverySeconds, int graceSeconds)
{
return (new AlertDefaultMonitor()
.withName(name)
.withKind(AlertMonitorKind.HEARTBEAT)
.withDefinitionValue("heartbeatKey", heartbeatKey)
.withDefinitionValue("expectedEverySeconds", expectedEverySeconds)
.withDefinitionValue("graceSeconds", graceSeconds));
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (this.name);
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public AlertDefaultMonitor withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for kind
*******************************************************************************/
public AlertMonitorKind getKind()
{
return (this.kind);
}
/*******************************************************************************
** Fluent setter for kind
*******************************************************************************/
public AlertDefaultMonitor withKind(AlertMonitorKind kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for definition
*******************************************************************************/
public Map<String, Serializable> getDefinition()
{
return (this.definition);
}
/*******************************************************************************
** Fluent setter for definition
*******************************************************************************/
public AlertDefaultMonitor withDefinition(Map<String, Serializable> definition)
{
this.definition = definition == null ? new LinkedHashMap<>() : new LinkedHashMap<>(definition);
return (this);
}
/*******************************************************************************
** Set one definition value.
*******************************************************************************/
public AlertDefaultMonitor withDefinitionValue(String key, Serializable value)
{
this.definition.put(key, value);
return (this);
}
/*******************************************************************************
** Getter for perRecord
*******************************************************************************/
public Boolean getPerRecord()
{
return (this.perRecord);
}
/*******************************************************************************
** Fluent setter for perRecord (one alert per matching record, instead of one
** aggregate alert)
*******************************************************************************/
public AlertDefaultMonitor withPerRecord(Boolean perRecord)
{
this.perRecord = perRecord;
return (this);
}
/*******************************************************************************
** Getter for severity
*******************************************************************************/
public AlertSeverity getSeverity()
{
return (this.severity);
}
/*******************************************************************************
** Fluent setter for severity (default: the type's severity)
*******************************************************************************/
public AlertDefaultMonitor withSeverity(AlertSeverity severity)
{
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for scheduleSeconds
*******************************************************************************/
public Integer getScheduleSeconds()
{
return (this.scheduleSeconds);
}
/*******************************************************************************
** Fluent setter for scheduleSeconds (how often to evaluate)
*******************************************************************************/
public AlertDefaultMonitor withScheduleSeconds(Integer scheduleSeconds)
{
this.scheduleSeconds = scheduleSeconds;
return (this);
}
/*******************************************************************************
** Getter for forMinutes
*******************************************************************************/
public Integer getForMinutes()
{
return (this.forMinutes);
}
/*******************************************************************************
** Fluent setter for forMinutes (how long a condition must hold before it fires)
*******************************************************************************/
public AlertDefaultMonitor withForMinutes(Integer forMinutes)
{
this.forMinutes = forMinutes;
return (this);
}
}
@@ -23,6 +23,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** A link from an alert to where a person fixes it: a record, a table with a
** filter, an app or widget path, or an absolute URL. Exactly one target is
** expected, so construct these with the static factories.
@@ -1,48 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
/*******************************************************************************
** The kinds of monitor an alerts implementation evaluates. Declared in core
** so producers can describe a seeded (default) monitor for their alert types
** without depending on the implementation.
*******************************************************************************/
public enum AlertMonitorKind
{
/** count of rows matching a filter passes a threshold. */
TABLE_QUERY,
/** an aggregate (count, sum, avg, min, max) crosses a band. */
AGGREGATE,
/** nothing was seen (a heartbeat, a process success, a newest row) within an interval. */
HEARTBEAT,
/** a process run failed. */
PROCESS_FAILED,
/** a matching ESB event arrived. */
ESB_EVENT,
/** a code check returned the currently-failing conditions. */
CODE,
/** alerts ingested from an external system (e.g. Alertmanager). */
EXTERNAL
}
@@ -1,223 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
/*******************************************************************************
** A record that an alert is about. The label is internal (shown in-app to
** users who can see the record), never sent to an external channel.
*******************************************************************************/
public class AlertRecordRef implements Serializable
{
private String tableName;
private Serializable primaryKey;
private String label;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertRecordRef()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertRecordRef(String tableName, Serializable primaryKey)
{
this.tableName = tableName;
this.primaryKey = primaryKey;
}
/*******************************************************************************
** One ref per primary key, all in the same table. Null keys are skipped.
*******************************************************************************/
public static List<AlertRecordRef> of(String tableName, Collection<? extends Serializable> primaryKeys)
{
List<AlertRecordRef> refs = new ArrayList<>();
if(primaryKeys != null)
{
for(Serializable primaryKey : primaryKeys)
{
if(primaryKey != null)
{
refs.add(new AlertRecordRef(tableName, primaryKey));
}
}
}
return (refs);
}
/*******************************************************************************
** One ref to one record.
*******************************************************************************/
public static AlertRecordRef of(String tableName, Serializable primaryKey)
{
return (new AlertRecordRef(tableName, primaryKey));
}
/*******************************************************************************
** Two refs are equal when they name the same table and the same key (keys
** compared by their string form, so 1 and "1" match).
*******************************************************************************/
@Override
public boolean equals(Object o)
{
if(this == o)
{
return (true);
}
if(!(o instanceof AlertRecordRef that))
{
return (false);
}
return (Objects.equals(tableName, that.tableName) && Objects.equals(String.valueOf(primaryKey), String.valueOf(that.primaryKey)));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public int hashCode()
{
return (Objects.hash(tableName, String.valueOf(primaryKey)));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String toString()
{
return (tableName + ":" + primaryKey);
}
/*******************************************************************************
** Getter for tableName
*******************************************************************************/
public String getTableName()
{
return (this.tableName);
}
/*******************************************************************************
** Setter for tableName
*******************************************************************************/
public void setTableName(String tableName)
{
this.tableName = tableName;
}
/*******************************************************************************
** Fluent setter for tableName
*******************************************************************************/
public AlertRecordRef withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for primaryKey
*******************************************************************************/
public Serializable getPrimaryKey()
{
return (this.primaryKey);
}
/*******************************************************************************
** Setter for primaryKey
*******************************************************************************/
public void setPrimaryKey(Serializable primaryKey)
{
this.primaryKey = primaryKey;
}
/*******************************************************************************
** Fluent setter for primaryKey
*******************************************************************************/
public AlertRecordRef withPrimaryKey(Serializable primaryKey)
{
this.primaryKey = primaryKey;
return (this);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (this.label);
}
/*******************************************************************************
** Setter for label
*******************************************************************************/
public void setLabel(String label)
{
this.label = label;
}
/*******************************************************************************
** Fluent setter for label (internal: shown in-app only, never sent out)
*******************************************************************************/
public AlertRecordRef withLabel(String label)
{
this.label = label;
return (this);
}
}
@@ -1,150 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.io.Serializable;
import java.time.Instant;
/*******************************************************************************
** A producer's "this problem is gone", passed to QAlerts.resolve. Resolving
** a key that is not open is a no-op.
*******************************************************************************/
public class AlertResolution implements Serializable
{
private String alertTypeName;
private String dedupKey;
private String note;
private Instant resolvedAt;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertResolution()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertResolution(String alertTypeName, String dedupKey, String note)
{
this.alertTypeName = alertTypeName;
this.dedupKey = dedupKey;
this.note = note;
}
/*******************************************************************************
** The dedup key to use: the one given, or the type name.
*******************************************************************************/
public String getEffectiveDedupKey()
{
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
}
/*******************************************************************************
** Getter for alertTypeName
*******************************************************************************/
public String getAlertTypeName()
{
return (this.alertTypeName);
}
/*******************************************************************************
** Fluent setter for alertTypeName
*******************************************************************************/
public AlertResolution withAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
return (this);
}
/*******************************************************************************
** Getter for dedupKey
*******************************************************************************/
public String getDedupKey()
{
return (this.dedupKey);
}
/*******************************************************************************
** Fluent setter for dedupKey
*******************************************************************************/
public AlertResolution withDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
return (this);
}
/*******************************************************************************
** Getter for note
*******************************************************************************/
public String getNote()
{
return (this.note);
}
/*******************************************************************************
** Fluent setter for note
*******************************************************************************/
public AlertResolution withNote(String note)
{
this.note = note;
return (this);
}
/*******************************************************************************
** Getter for resolvedAt
*******************************************************************************/
public Instant getResolvedAt()
{
return (this.resolvedAt);
}
/*******************************************************************************
** Fluent setter for resolvedAt (default: when QAlerts.resolve is called)
*******************************************************************************/
public AlertResolution withResolvedAt(Instant resolvedAt)
{
this.resolvedAt = resolvedAt;
return (this);
}
}
@@ -1,73 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.util.Locale;
/*******************************************************************************
** How urgent an alert is. Ordered from least to most severe, so
** isAtLeast / compareTo can be used for "minimum severity" checks.
**
** An alert means "a person should do something". INFO is for worth-a-glance
** items that never page anyone; external channels ignore it by default.
*******************************************************************************/
public enum AlertSeverity
{
INFO,
WARNING,
ERROR,
CRITICAL;
/*******************************************************************************
** True when this severity is the same as, or more severe than, the other.
** A null other is treated as INFO (so everything is at least it).
*******************************************************************************/
public boolean isAtLeast(AlertSeverity other)
{
return (other == null || this.compareTo(other) >= 0);
}
/*******************************************************************************
** Leniently map a severity label from another system (Prometheus, a
** producer's own enum, etc) to an AlertSeverity. Case-insensitive; accepts
** common synonyms ("warn", "err", "fatal", "page", "info"/"informational").
** Returns the fallback for null, blank or unknown values.
*******************************************************************************/
public static AlertSeverity fromLabel(String label, AlertSeverity fallback)
{
if(label == null || label.isBlank())
{
return (fallback);
}
return switch(label.trim().toLowerCase(Locale.ROOT))
{
case "info", "informational", "notice", "low" -> INFO;
case "warning", "warn", "medium" -> WARNING;
case "error", "err", "high" -> ERROR;
case "critical", "crit", "fatal", "page", "emergency" -> CRITICAL;
default -> fallback;
};
}
}
@@ -1,489 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/*******************************************************************************
** One "this is a problem" signal from a producer, passed to QAlerts.raise.
** Raising is idempotent on (alertTypeName, dedupKey): repeats refresh one open
** alert, they never duplicate it.
**
** Text fields have different reach. title and summary are external-safe: a
** producer must write them without personal data, because they may be sent
** to email or chat. details (and record ref labels) are internal: shown
** in-app only, to users who can see the alert.
**
** Bounds: at most MAX_RECORD_REFS record refs (extras are dropped, and count
** keeps the true total) and MAX_ATTRIBUTES attributes (extras are ignored).
*******************************************************************************/
public class AlertSignal implements Serializable
{
public static final int MAX_RECORD_REFS = 100;
public static final int MAX_ATTRIBUTES = 20;
private String alertTypeName;
private String dedupKey;
private AlertSeverity severity;
private String title;
private String summary;
private String details;
private List<AlertRecordRef> recordRefs = new ArrayList<>();
private List<AlertLink> links = new ArrayList<>();
private Map<String, String> attributes = new LinkedHashMap<>();
private Instant occurredAt;
private Integer count;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertSignal()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertSignal(String alertTypeName)
{
this.alertTypeName = alertTypeName;
}
/*******************************************************************************
** The dedup key to use: the one given, or the type name (one alert per type).
*******************************************************************************/
public String getEffectiveDedupKey()
{
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
}
/*******************************************************************************
** The record count this signal describes: count when set, otherwise the
** number of record refs (null when there are none).
*******************************************************************************/
public Integer getEffectiveCount()
{
if(count != null)
{
return (count);
}
return (recordRefs.isEmpty() ? null : recordRefs.size());
}
/*******************************************************************************
** Getter for alertTypeName
*******************************************************************************/
public String getAlertTypeName()
{
return (this.alertTypeName);
}
/*******************************************************************************
** Setter for alertTypeName
*******************************************************************************/
public void setAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
}
/*******************************************************************************
** Fluent setter for alertTypeName
*******************************************************************************/
public AlertSignal withAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
return (this);
}
/*******************************************************************************
** Getter for dedupKey
*******************************************************************************/
public String getDedupKey()
{
return (this.dedupKey);
}
/*******************************************************************************
** Setter for dedupKey
*******************************************************************************/
public void setDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
}
/*******************************************************************************
** Fluent setter for dedupKey - a stable key, so repeats update one open alert.
*******************************************************************************/
public AlertSignal withDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
return (this);
}
/*******************************************************************************
** Getter for severity
*******************************************************************************/
public AlertSeverity getSeverity()
{
return (this.severity);
}
/*******************************************************************************
** Setter for severity
*******************************************************************************/
public void setSeverity(AlertSeverity severity)
{
this.severity = severity;
}
/*******************************************************************************
** Fluent setter for severity (optional; defaults to the type's severity)
*******************************************************************************/
public AlertSignal withSeverity(AlertSeverity severity)
{
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for title
*******************************************************************************/
public String getTitle()
{
return (this.title);
}
/*******************************************************************************
** Setter for title
*******************************************************************************/
public void setTitle(String title)
{
this.title = title;
}
/*******************************************************************************
** Fluent setter for title (external-safe: no personal data)
*******************************************************************************/
public AlertSignal withTitle(String title)
{
this.title = title;
return (this);
}
/*******************************************************************************
** Getter for summary
*******************************************************************************/
public String getSummary()
{
return (this.summary);
}
/*******************************************************************************
** Setter for summary
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent setter for summary (external-safe: no personal data)
*******************************************************************************/
public AlertSignal withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Getter for details
*******************************************************************************/
public String getDetails()
{
return (this.details);
}
/*******************************************************************************
** Setter for details
*******************************************************************************/
public void setDetails(String details)
{
this.details = details;
}
/*******************************************************************************
** Fluent setter for details (internal: in-app only, never sent externally)
*******************************************************************************/
public AlertSignal withDetails(String details)
{
this.details = details;
return (this);
}
/*******************************************************************************
** Getter for recordRefs
*******************************************************************************/
public List<AlertRecordRef> getRecordRefs()
{
return (this.recordRefs);
}
/*******************************************************************************
** Setter for recordRefs
*******************************************************************************/
public void setRecordRefs(List<AlertRecordRef> recordRefs)
{
this.recordRefs = new ArrayList<>();
withRecordRefs(recordRefs);
}
/*******************************************************************************
** Add record refs. Beyond MAX_RECORD_REFS they are dropped, and count is
** set to the true total so the alert still says how many there were.
*******************************************************************************/
public AlertSignal withRecordRefs(Collection<AlertRecordRef> recordRefs)
{
if(recordRefs == null)
{
return (this);
}
int total = this.recordRefs.size();
for(AlertRecordRef recordRef : recordRefs)
{
if(recordRef == null)
{
continue;
}
total++;
if(this.recordRefs.size() < MAX_RECORD_REFS)
{
this.recordRefs.add(recordRef);
}
}
if(total > MAX_RECORD_REFS && (count == null || count < total))
{
count = total;
}
return (this);
}
/*******************************************************************************
** Add one record ref.
*******************************************************************************/
public AlertSignal withRecordRef(AlertRecordRef recordRef)
{
return (withRecordRefs(recordRef == null ? null : List.of(recordRef)));
}
/*******************************************************************************
** Getter for links
*******************************************************************************/
public List<AlertLink> getLinks()
{
return (this.links);
}
/*******************************************************************************
** Setter for links
*******************************************************************************/
public void setLinks(List<AlertLink> links)
{
this.links = links == null ? new ArrayList<>() : new ArrayList<>(links);
}
/*******************************************************************************
** Add a link to where the problem is fixed.
*******************************************************************************/
public AlertSignal withLink(AlertLink link)
{
if(link != null)
{
this.links.add(link);
}
return (this);
}
/*******************************************************************************
** Getter for attributes
*******************************************************************************/
public Map<String, String> getAttributes()
{
return (this.attributes);
}
/*******************************************************************************
** Setter for attributes
*******************************************************************************/
public void setAttributes(Map<String, String> attributes)
{
this.attributes = new LinkedHashMap<>();
if(attributes != null)
{
attributes.forEach(this::withAttribute);
}
}
/*******************************************************************************
** Add a routing label (not displayed by default). At most MAX_ATTRIBUTES
** keys; a new key beyond that is ignored (replacing an existing key is fine).
*******************************************************************************/
public AlertSignal withAttribute(String key, String value)
{
if(key != null && (attributes.containsKey(key) || attributes.size() < MAX_ATTRIBUTES))
{
attributes.put(key, value);
}
return (this);
}
/*******************************************************************************
** Getter for occurredAt
*******************************************************************************/
public Instant getOccurredAt()
{
return (this.occurredAt);
}
/*******************************************************************************
** Setter for occurredAt
*******************************************************************************/
public void setOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
}
/*******************************************************************************
** Fluent setter for occurredAt (default: when QAlerts.raise is called)
*******************************************************************************/
public AlertSignal withOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
return (this);
}
/*******************************************************************************
** Getter for count
*******************************************************************************/
public Integer getCount()
{
return (this.count);
}
/*******************************************************************************
** Setter for count
*******************************************************************************/
public void setCount(Integer count)
{
this.count = count;
}
/*******************************************************************************
** Fluent setter for count - for aggregate alerts, e.g. "37 records".
*******************************************************************************/
public AlertSignal withCount(Integer count)
{
this.count = count;
return (this);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.time.Instant;
/*******************************************************************************
** Where QAlerts sends signals. The default is LoggingAlertSink; an alerts
** implementation (such as qbit-alerts) registers its own through
** AlertsInstanceMetaData.withSink.
**
** Methods are called after the caller's transaction commits (or at once, when
** there is none), on the caller's thread, so they must be fast: queue the
** work, don't do database writes or network calls inline. Anything they
** throw is caught, logged and counted by QAlerts; it never reaches the
** producer.
**
** A new instance is not loaded per call: QAlerts loads the sink once per
** QInstance and reuses it, so implementations must be thread-safe.
*******************************************************************************/
public interface AlertSinkInterface
{
/***************************************************************************
** Record (or refresh) an alert. The signal's occurredAt is always set.
***************************************************************************/
void raise(AlertSignal signal);
/***************************************************************************
** Resolve an open alert, if there is one. resolvedAt is always set.
***************************************************************************/
void resolve(AlertResolution resolution);
/***************************************************************************
** Note that a dead-man's switch beat.
***************************************************************************/
void heartbeat(String heartbeatKey, Instant at);
/***************************************************************************
** Send a notification to people (an audience). The default does nothing,
** so sinks written before notifications keep working.
***************************************************************************/
default void notify(NotificationSignal signal)
{
//////////////////////////////////////////////////////////////
// noop - sinks that don't carry notifications ignore them //
//////////////////////////////////////////////////////////////
}
}
@@ -1,228 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** Instance-level alerts meta-data: the sink that QAlerts sends signals to
** (default: LoggingAlertSink) and every declared QAlertTypeMetaData. It is
** supplemental meta-data, so any module or qbit can add to it.
*******************************************************************************/
public class AlertsInstanceMetaData implements QSupplementalInstanceMetaData
{
public static final String NAME = "alertsQBitStandIn";
/** alert type names: lowercase, dotted, at least two segments. */
public static final Pattern ALERT_TYPE_NAME_PATTERN = Pattern.compile("^[a-z0-9]+(\\.[a-z0-9-]+)+$");
private QCodeReference sink;
private Map<String, QAlertTypeMetaData> alertTypes = new LinkedHashMap<>();
private List<String> duplicateAlertTypes = new ArrayList<>();
private transient AlertSinkInterface loadedSink;
/*******************************************************************************
** Get the instance's alerts meta-data, or null when nothing added it.
*******************************************************************************/
public static AlertsInstanceMetaData of(QInstance qInstance)
{
return (qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME));
}
/*******************************************************************************
** Get the instance's alerts meta-data, adding an empty one if it has none.
*******************************************************************************/
public static AlertsInstanceMetaData ofOrWithNew(QInstance qInstance)
{
return (QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, AlertsInstanceMetaData::new));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (NAME);
}
/*******************************************************************************
** Getter for sink
*******************************************************************************/
public QCodeReference getSink()
{
return (this.sink);
}
/*******************************************************************************
** Fluent setter for sink - an AlertSinkInterface implementation. An
** alerts implementation sets this; producers never do.
*******************************************************************************/
public AlertsInstanceMetaData withSink(QCodeReference sink)
{
this.sink = sink;
this.loadedSink = null;
return (this);
}
/*******************************************************************************
** The sink instance QAlerts loaded for this meta-data (cached; null until
** first use).
*******************************************************************************/
AlertSinkInterface getLoadedSink()
{
return (this.loadedSink);
}
/*******************************************************************************
** Cache the loaded sink instance.
*******************************************************************************/
void setLoadedSink(AlertSinkInterface loadedSink)
{
this.loadedSink = loadedSink;
}
/*******************************************************************************
** Getter for alertTypes
*******************************************************************************/
public Map<String, QAlertTypeMetaData> getAlertTypes()
{
return (this.alertTypes);
}
/*******************************************************************************
** Find a declared type by name, or null.
*******************************************************************************/
public QAlertTypeMetaData getAlertType(String name)
{
return (name == null ? null : alertTypes.get(name));
}
/*******************************************************************************
** Declare an alert type. Adding the same object again is a no-op; adding a
** different type with a name already taken is a validation error.
*******************************************************************************/
public AlertsInstanceMetaData withAlertType(QAlertTypeMetaData alertType)
{
if(alertType == null)
{
return (this);
}
QAlertTypeMetaData existing = alertTypes.get(alertType.getName());
if(existing != null && existing != alertType)
{
duplicateAlertTypes.add(alertType.getName());
return (this);
}
alertTypes.put(alertType.getName(), alertType);
return (this);
}
/*******************************************************************************
** Names must be unique and well-formed, subject tables must exist, a
** visibility permission must not be blank, heartbeat default monitors need
** an interval, and the sink must implement AlertSinkInterface.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, QInstanceValidator validator)
{
if(sink != null)
{
validator.validateSimpleCodeReference("Alerts sink: ", sink, AlertSinkInterface.class);
}
for(String duplicate : duplicateAlertTypes)
{
validator.assertCondition(false, "Alert type " + duplicate + " is declared more than once.");
}
for(Map.Entry<String, QAlertTypeMetaData> entry : alertTypes.entrySet())
{
String name = entry.getKey();
QAlertTypeMetaData alertType = entry.getValue();
String prefix = "Alert type " + name + ": ";
if(!validator.assertCondition(name != null && ALERT_TYPE_NAME_PATTERN.matcher(name).matches(), prefix + "name must be lowercase and dotted (e.g. cdn.purge.dead), matching " + ALERT_TYPE_NAME_PATTERN.pattern()))
{
continue;
}
validator.assertCondition(alertType.getDefaultSeverity() != null, prefix + "defaultSeverity is required.");
if(alertType.getSubjectTableName() != null)
{
validator.assertCondition(qInstance.getTable(alertType.getSubjectTableName()) != null, prefix + "subjectTableName " + alertType.getSubjectTableName() + " is not a table in the instance.");
}
if(alertType.getVisibilityPermission() != null)
{
validator.assertCondition(!alertType.getVisibilityPermission().isBlank(), prefix + "visibilityPermission must not be blank.");
}
if(alertType.getResolveMode() == ResolveMode.QUIET_PERIOD)
{
validator.assertCondition(alertType.getQuietPeriod() != null && !alertType.getQuietPeriod().isNegative() && !alertType.getQuietPeriod().isZero(), prefix + "a QUIET_PERIOD resolve mode needs a positive quietPeriod.");
}
for(AlertDefaultMonitor monitor : alertType.getDefaultMonitors())
{
validator.assertCondition(monitor.getKind() != null, prefix + "a default monitor needs a kind.");
if(monitor.getKind() == AlertMonitorKind.HEARTBEAT)
{
Object every = monitor.getDefinition().get("expectedEverySeconds");
validator.assertCondition(every instanceof Number number && number.longValue() > 0, prefix + "a heartbeat default monitor needs a positive expectedEverySeconds.");
}
}
}
}
}
@@ -1,54 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
/*******************************************************************************
** How much of an alert an external channel (email, chat, paging, webhook) may
** include. A channel has a level; an alert type has a ceiling, and the lower
** of the two applies.
*******************************************************************************/
public enum DetailLevel
{
/** severity, type label, group, title, age and one link back to the alert. */
MINIMAL,
/** MINIMAL plus the summary and the counts. */
STANDARD,
/** STANDARD plus details and record labels (admin opt-in). */
FULL;
/*******************************************************************************
** The lower (less revealing) of two levels. Null means "no limit".
*******************************************************************************/
public static DetailLevel lowerOf(DetailLevel a, DetailLevel b)
{
if(a == null)
{
return (b);
}
if(b == null)
{
return (a);
}
return (a.compareTo(b) <= 0 ? a : b);
}
}
@@ -26,12 +26,16 @@ import java.util.Objects;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** Who a notification is for, resolved to users when it is sent (stand-in for
** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience).
*******************************************************************************/
public class NotificationAudience implements Serializable
{
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** The kinds of audience.
*******************************************************************************/
public enum Type
@@ -19,6 +19,8 @@ package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** What a notification is about (stand-in for core's
** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter
** and mute by category; SECURITY can never be muted in-app.
@@ -19,6 +19,8 @@ package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** How a notification looks to its reader: INFO and SUCCESS are good news,
** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for
** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity).
@@ -23,6 +23,8 @@ import java.time.Instant;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** A notification for people (stand-in for core's
** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell
** them, how urgent it is, what it is about, and who it is for. Send it with
@@ -1,410 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
/*******************************************************************************
** Declares a kind of problem a qbit or app can report (e.g. cdn.purge.dead),
** so the system "knows about" every alert source. An alerts implementation
** syncs these into a table at startup, where admins can see and tune them.
**
** Add it to an instance like any top-level meta-data (qInstance.add(...), or
** from a MetaDataProducer); it lands in AlertsInstanceMetaData.alertTypes.
** Names are lowercase and dotted: ^[a-z0-9]+(\.[a-z0-9-]+)+$
*******************************************************************************/
public class QAlertTypeMetaData implements TopLevelMetaDataInterface, SourceQBitAware
{
private String name;
private String label;
private String sourceName;
private String sourceQBitName;
private String groupName;
private AlertSeverity defaultSeverity = AlertSeverity.WARNING;
private String description;
private String howToFix;
private String subjectTableName;
private String visibilityPermission;
private ResolveMode resolveMode = ResolveMode.PRODUCER;
private Duration quietPeriod;
private List<AlertDefaultMonitor> defaultMonitors = new ArrayList<>();
private DetailLevel externalDetailCeiling = DetailLevel.STANDARD;
/*******************************************************************************
** Find a declared alert type by name, or null.
*******************************************************************************/
public static QAlertTypeMetaData of(QInstance qInstance, String name)
{
AlertsInstanceMetaData alertsInstanceMetaData = AlertsInstanceMetaData.of(qInstance);
return (alertsInstanceMetaData == null ? null : alertsInstanceMetaData.getAlertType(name));
}
/*******************************************************************************
** Adds this type to the instance's AlertsInstanceMetaData (creating it).
*******************************************************************************/
@Override
public void addSelfToInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(this);
}
/*******************************************************************************
** The source shown to people: sourceName if set, otherwise the qbit that
** produced this meta-data.
*******************************************************************************/
public String getEffectiveSourceName()
{
return (sourceName != null ? sourceName : sourceQBitName);
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
@Override
public String getName()
{
return (this.name);
}
/*******************************************************************************
** Setter for name
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public QAlertTypeMetaData withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (this.label);
}
/*******************************************************************************
** Fluent setter for label
*******************************************************************************/
public QAlertTypeMetaData withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for sourceName
*******************************************************************************/
public String getSourceName()
{
return (this.sourceName);
}
/*******************************************************************************
** Fluent setter for sourceName
*******************************************************************************/
public QAlertTypeMetaData withSourceName(String sourceName)
{
this.sourceName = sourceName;
return (this);
}
/*******************************************************************************
** Getter for sourceQBitName
*******************************************************************************/
@Override
public String getSourceQBitName()
{
return (this.sourceQBitName);
}
/*******************************************************************************
** Setter for sourceQBitName
*******************************************************************************/
@Override
public void setSourceQBitName(String sourceQBitName)
{
this.sourceQBitName = sourceQBitName;
}
/*******************************************************************************
** Fluent setter for sourceQBitName
*******************************************************************************/
@Override
public QAlertTypeMetaData withSourceQBitName(String sourceQBitName)
{
this.sourceQBitName = sourceQBitName;
return (this);
}
/*******************************************************************************
** Getter for groupName
*******************************************************************************/
public String getGroupName()
{
return (this.groupName);
}
/*******************************************************************************
** Fluent setter for groupName (the default group; created if missing)
*******************************************************************************/
public QAlertTypeMetaData withGroupName(String groupName)
{
this.groupName = groupName;
return (this);
}
/*******************************************************************************
** Getter for defaultSeverity
*******************************************************************************/
public AlertSeverity getDefaultSeverity()
{
return (this.defaultSeverity);
}
/*******************************************************************************
** Fluent setter for defaultSeverity
*******************************************************************************/
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
{
this.defaultSeverity = defaultSeverity;
return (this);
}
/*******************************************************************************
** Getter for description
*******************************************************************************/
public String getDescription()
{
return (this.description);
}
/*******************************************************************************
** Fluent setter for description
*******************************************************************************/
public QAlertTypeMetaData withDescription(String description)
{
this.description = description;
return (this);
}
/*******************************************************************************
** Getter for howToFix
*******************************************************************************/
public String getHowToFix()
{
return (this.howToFix);
}
/*******************************************************************************
** Fluent setter for howToFix
*******************************************************************************/
public QAlertTypeMetaData withHowToFix(String howToFix)
{
this.howToFix = howToFix;
return (this);
}
/*******************************************************************************
** Getter for subjectTableName
*******************************************************************************/
public String getSubjectTableName()
{
return (this.subjectTableName);
}
/*******************************************************************************
** Fluent setter for subjectTableName (drives visibility and the record panel)
*******************************************************************************/
public QAlertTypeMetaData withSubjectTableName(String subjectTableName)
{
this.subjectTableName = subjectTableName;
return (this);
}
/*******************************************************************************
** Getter for visibilityPermission
*******************************************************************************/
public String getVisibilityPermission()
{
return (this.visibilityPermission);
}
/*******************************************************************************
** Fluent setter for visibilityPermission (an extra gate on who may see it)
*******************************************************************************/
public QAlertTypeMetaData withVisibilityPermission(String visibilityPermission)
{
this.visibilityPermission = visibilityPermission;
return (this);
}
/*******************************************************************************
** Getter for resolveMode
*******************************************************************************/
public ResolveMode getResolveMode()
{
return (this.resolveMode);
}
/*******************************************************************************
** Fluent setter for resolveMode
*******************************************************************************/
public QAlertTypeMetaData withResolveMode(ResolveMode resolveMode)
{
this.resolveMode = resolveMode;
return (this);
}
/*******************************************************************************
** Resolve automatically after this long with no raise (sets the mode to
** QUIET_PERIOD).
*******************************************************************************/
public QAlertTypeMetaData withQuietPeriod(Duration quietPeriod)
{
this.quietPeriod = quietPeriod;
this.resolveMode = ResolveMode.QUIET_PERIOD;
return (this);
}
/*******************************************************************************
** Getter for quietPeriod
*******************************************************************************/
public Duration getQuietPeriod()
{
return (this.quietPeriod);
}
/*******************************************************************************
** Getter for defaultMonitors
*******************************************************************************/
public List<AlertDefaultMonitor> getDefaultMonitors()
{
return (this.defaultMonitors);
}
/*******************************************************************************
** Add a seeded monitor for this type.
*******************************************************************************/
public QAlertTypeMetaData withDefaultMonitor(AlertDefaultMonitor defaultMonitor)
{
if(defaultMonitor != null)
{
this.defaultMonitors.add(defaultMonitor);
}
return (this);
}
/*******************************************************************************
** Getter for externalDetailCeiling
*******************************************************************************/
public DetailLevel getExternalDetailCeiling()
{
return (this.externalDetailCeiling);
}
/*******************************************************************************
** Fluent setter for externalDetailCeiling (the most any channel may include)
*******************************************************************************/
public QAlertTypeMetaData withExternalDetailCeiling(DetailLevel externalDetailCeiling)
{
this.externalDetailCeiling = externalDetailCeiling;
return (this);
}
}
@@ -1,38 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.spi;
/*******************************************************************************
** How an open alert of a type is closed automatically. Any mode also allows
** a person to resolve an alert manually, with a note.
*******************************************************************************/
public enum ResolveMode
{
/** the producer calls QAlerts.resolve when the condition clears. */
PRODUCER,
/** the monitor that raised it resolves it when its next evaluation is clean. */
MONITOR,
/** it resolves when no raise has been seen for the type's quiet period. */
QUIET_PERIOD,
/** only a person resolves it. */
MANUAL
}
@@ -16,16 +16,20 @@
/*******************************************************************************
** STAND-IN for the QQQ core alerts producer SPI (QRun-IO/qqq#1108), which is
** not yet in a released QQQ 4.1.0 RC. These classes are copies of
** com.kingsrook.qqq.backend.core.alerts (AlertSignal, AlertSeverity,
** QAlertTypeMetaData and friends) under this package, so the qbit can be
** built and tested against 4.1.0-RC.4 now.
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** Replace when the RC with qqq#1108 ships: delete this package, change the
** imports to com.kingsrook.qqq.backend.core.alerts, and register
** QbitAlertsSink in core's AlertsInstanceMetaData (see AlertsQBitProducer).
** Until then, producers in a host reach the qbit through
** AlertsRuntime.sink() (or its monitors), not through core's QAlerts.
** Only NotificationSignal, NotificationAudience, NotificationCategory,
** NotificationSeverity and AlertLink remain here. Core 4.1.0-RC.5 ships the
** alert SPI, but not the notification API planned in QRun-IO/qqq#1125.
**
** Released qbit-easypost-tracking 0.1.x calls AlertsRuntime.sink().notify
** with these types. AlertLink remains because NotificationSignal's public
** signatures use it. Keep their binary signatures until producers migrate
** to core QAlerts.notify in RC.6; no local sink interface is needed because
** AlertsRuntime.sink() still returns QbitAlertsSink.
**
** Alerts use com.kingsrook.qqq.backend.core.alerts. AlertsQBitProducer
** registers AlertsSinkAdapter through core AlertsInstanceMetaData, so host
** QAlerts.raise/resolve/heartbeat calls reach this qbit's runtime.
*******************************************************************************/
package com.kingsrook.qbits.alerts.spi;
@@ -27,8 +27,8 @@ import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer;
import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -28,12 +28,12 @@ import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
@@ -32,16 +32,16 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
@@ -25,8 +25,8 @@ import com.kingsrook.qbits.alerts.delivery.AlertMessage;
import com.kingsrook.qbits.alerts.delivery.AlertSecretResolver;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qbits.alerts.engine.AlertCheckInterface;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
@@ -26,10 +26,11 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -172,8 +173,8 @@ class ExternalChannelsTest
String timestamp = request.headers().get("Webhook-timestamp").get(0);
String signature = request.headers().get("Webhook-signature").get(0);
assertThat(id).startsWith("msg_");
String expected = StandardWebhookSigner.sign(id, Long.parseLong(timestamp), request.body().getBytes(StandardCharsets.UTF_8), "whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=");
assertThat(signature).isEqualTo("v1," + expected);
new WebhookVerifier(List.of(WebhookSecret.parse("whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=")))
.verify(Map.of("webhook-id", id, "webhook-timestamp", timestamp, "webhook-signature", signature), request.body().getBytes(StandardCharsets.UTF_8));
assertThat(request.body()).contains("\"type\":\"qqq.alert.resolved\"").contains("\"dedupKey\":\"provider:cf\"");
////////////////////////////////////////////////////
@@ -22,10 +22,10 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.alerts.delivery;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Fixed wire vectors generated independently with Python's hmac/hashlib.
** Cover both secret encodings, short legacy keys and a key over one block.
*******************************************************************************/
class StandardWebhookSignerTest
{
/*******************************************************************************
** Raw and whsec_ secrets produce the exact same legacy header values.
*******************************************************************************/
@ParameterizedTest
@MethodSource("wireVectors")
void testLegacyWireFormat(String key, String expectedSignature)
{
Instant timestamp = Instant.ofEpochSecond(1791806400L);
byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8);
Map<String, String> expected = Map.of("webhook-id", "msg_test", "webhook-timestamp", "1791806400", "webhook-signature", expectedSignature);
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, " " + key + " ")).isEqualTo(expected);
String encoded = "whsec_" + Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, encoded)).isEqualTo(expected);
}
/*******************************************************************************
** Independent expected signatures, including the 15/16-byte core boundary.
*******************************************************************************/
private static Stream<Arguments> wireVectors()
{
return (Stream.of(
Arguments.of("short", "v1,Jz84jUiA/gQwIt0Td1DzUA/OE5soKzC5H9C5e94lliM="),
Arguments.of("sixteen-byte-key", "v1,qep/FxanCVlGy2Zohku2Bgd3ufM4KNRcj5qT8t7blNA="),
Arguments.of("sixteen-byte-key!", "v1,tbs9jXA3r4N06ZLpuOe6V4RasiRwYwbM2+2ZxRu8lNU="),
Arguments.of("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "v1,K6s1XISWGs8vzyUo3Ecush1eoxy/d5CiND+gzS70I8U=")));
}
/*******************************************************************************
** Empty or malformed keys still fail before any request is sent.
*******************************************************************************/
@Test
void testInvalidSecrets()
{
for(String secret : new String[] { null, "", " ", "whsec_", "whsec_!" })
{
assertThatThrownBy(() -> StandardWebhookSigner.headers("msg_test", Instant.EPOCH, null, secret)).isInstanceOf(IllegalArgumentException.class);
}
}
}
@@ -40,19 +40,19 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
@@ -309,11 +309,11 @@ public class AlertsDemoServer extends AbstractQQQApplication
{
runtime.sink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
.withSummary("The dump step exited with an error.").withDetails("pg_dump: error: connection to server lost")
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(AlertLink.toRecord("backup_run", runId, "Open the run")));
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run")));
}
else
{
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
runtime.sink().heartbeat("backups.nightly-db", clock.instant());
}
}
@@ -324,7 +324,7 @@ public class AlertsDemoServer extends AbstractQQQApplication
if(day != 27)
{
clock.set(clock.instant().plus(Duration.ofHours(3)));
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
}
}
if(day == 25)
@@ -35,13 +35,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
@@ -27,13 +27,13 @@ import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -29,19 +29,19 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -24,9 +24,13 @@ import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
import com.kingsrook.qbits.alerts.processes.AlertsScheduledSteps;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -41,6 +45,38 @@ import static org.assertj.core.api.Assertions.assertThat;
class SinkAndScheduleTest extends BaseAlertsTest
{
/*******************************************************************************
** A host using only core reaches the qbit through instance metadata and
** the loadable adapter, including the asynchronous queue and persistence.
*******************************************************************************/
@Test
void testCoreProducerReachesInstalledSink() throws Exception
{
AlertsInstanceMetaData metadata = AlertsInstanceMetaData.of(qInstance);
assertThat(metadata.getSink().getName()).isEqualTo(AlertsSinkAdapter.class.getName());
assertThat(qInstance.getSupplementalMetaData("alertsQBitStandIn")).isNull();
metadata.withAlertType(new QAlertTypeMetaData().withName("host.backup.failed").withDefaultSeverity(AlertSeverity.ERROR));
long failures = QAlerts.getProducerFailureCount();
QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed"));
QAlerts.heartbeat("host.backup.nightly");
runtime.sink().stop();
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
assertThat(alerts).hasSize(1);
assertThat(alerts.get(0).getValueString("title")).isEqualTo("Nightly backup failed");
assertThat(alerts.get(0).getValueString("severity")).isEqualTo("ERROR");
assertThat(alerts.get(0).getValueString("status")).isEqualTo("OPEN");
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded");
runtime.sink().stop();
assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED");
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
}
/*******************************************************************************
** Process signals on the background queue in these tests.
*******************************************************************************/
@@ -27,9 +27,11 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.api.AlertsApi;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
@@ -346,6 +348,36 @@ class NotificationsTest extends BaseAlertsTest
/*******************************************************************************
** Preserve the method descriptors used by released notification producers,
** including the local AlertLink, and deliver to the selected user's bell.
*******************************************************************************/
@Test
void testLegacyNotificationSinkSignaturesAndDelivery() throws Exception
{
assertThat(AlertsRuntime.class.getMethod("sink").getReturnType()).isEqualTo(QbitAlertsSink.class);
assertThat(QbitAlertsSink.class.getMethod("notify", NotificationSignal.class).getReturnType()).isEqualTo(void.class);
assertThat(NotificationSignal.class.getMethod("withLink", AlertLink.class).getReturnType()).isEqualTo(NotificationSignal.class);
assertThat(NotificationSignal.class.getMethod("getLink").getReturnType()).isEqualTo(AlertLink.class);
assertThat(NotificationSignal.class.getMethod("withAudience", NotificationAudience.class).getReturnType()).isEqualTo(NotificationSignal.class);
assertThat(AlertLink.class.getMethod("toRecord", String.class, Serializable.class, String.class).getReturnType()).isEqualTo(AlertLink.class);
assertThat(NotificationAudience.class.getMethod("user", String.class).getReturnType()).isEqualTo(NotificationAudience.class);
runtime.sink().notify(new NotificationSignal("Shipment delivered")
.withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB)
.withLink(AlertLink.toRecord("widget", 5, "Shipment"))
.withAudience(NotificationAudience.user("viewer-1")));
loginAs("viewer-1");
Map<String, Object> summary = new AlertsApi(runtime).inboxSummary();
assertThat(summary.get("unreadCount")).isEqualTo(1);
assertThat(items(summary).get(0)).containsEntry("title", "Shipment delivered").containsEntry("href", "/app/widget/5");
loginAs("viewer-2");
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(0);
}
/*******************************************************************************
** Audiences: roles from a directory, permission holders, warnings.
*******************************************************************************/