feat(engine): reminders and escalation policies (#27, part 2) #29
No files matched your search
@@ -23,6 +23,9 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- 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
|
||||
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
|
||||
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
|
||||
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
|
||||
- Optional external channels:
|
||||
- Slack (an Incoming Webhook, Block Kit)
|
||||
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
|
||||
|
||||
@@ -201,14 +201,57 @@ When qbit-email is produced in the same instance (and on the classpath; it is an
|
||||
- **Text** in the variables is the existing redacted, external-safe text. No record labels are passed.
|
||||
- **Config.** `messagingProviderName` and `fromAddress` are not needed (the From address comes from the email provider). Without qbit-email they work as before.
|
||||
|
||||
## Reminders and escalation
|
||||
|
||||
The hidden `alertsEscalateAndRemind` process runs every minute on `schedulerName`.
|
||||
Without a configured scheduler, hosts can invoke the process themselves. Apply
|
||||
Liquibase changesets 23–26 before running this version.
|
||||
|
||||
An OPEN, unacknowledged alert sends REMINDER deliveries to matching subscriptions
|
||||
whose events include REMINDER (an empty events list includes all kinds). Set the
|
||||
type's `reminderIntervalMinutes`: null defaults to 60 for CRITICAL alerts and off
|
||||
otherwise; zero disables reminders, including CRITICAL. The interval starts at
|
||||
the later of the latest raise and the latest successful notification. Delivery
|
||||
uses the normal permission, channel and suppression checks, writes NOTIFIED in
|
||||
the timeline when something is queued, and updates `lastNotifiedAt`.
|
||||
|
||||
Admins can create **Escalation Policies** in the Alerts app and add **Steps** on a
|
||||
policy's record screen. Attach `escalationPolicyId` to a type or group; a type's
|
||||
explicit policy takes precedence, including when that policy is disabled. Steps
|
||||
have a positive, unique `stepOrder`, a nonnegative `afterMinutes`, and exactly
|
||||
one USER (`userId`), TEAM (`teamId`) or CHANNEL (`channelId`) target. User and team
|
||||
targets use the users' normal in-app/email preferences. Steps apply only to OPEN
|
||||
alerts matching `onlyIfStatus` (default OPEN). Timing starts at the first raise
|
||||
or the latest reopen; repeated raises do not postpone escalation.
|
||||
|
||||
Each due step sends ESCALATED through the normal delivery checks, records an
|
||||
ESCALATED event with the previous and new levels, and advances `escalationLevel`.
|
||||
After the final step, positive `repeatAfterMinutes` and `maxRepeats` allow that
|
||||
many additional attempts at the final target. Null/zero disables repeats. Repeats
|
||||
leave the level unchanged and are counted in the timeline within the current
|
||||
open cycle. Reopening resets the level and repeat budget. One step runs per tick;
|
||||
a delayed scheduler catches up over subsequent minutes instead of sending a burst.
|
||||
Acknowledgement, snooze, resolution, mute, flapping, and a disabled or silent type
|
||||
stop timed notifications. Suppressed deliveries still count as escalation attempts.
|
||||
|
||||
Each alert's delivery rows, timeline and progress commit together. The persisted
|
||||
`timedNotificationCheckedAt` prevents duplicate attempts in the same minute,
|
||||
including when no recipient is eligible. Run this job on **one scheduler node**;
|
||||
there is no distributed lock between scheduler nodes. No ESCALATION feature
|
||||
switch exists in the current config; enable or disable individual policies.
|
||||
|
||||
`EXPIRED` remains a reserved resolution enum value. The design defines no alert
|
||||
expiration rule, so it is excluded from selectable resolution values and is never
|
||||
assigned automatically. The Resolve action continues to record MANUAL.
|
||||
|
||||
## Known limits (v1)
|
||||
|
||||
- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the two scheduled processes on one node, or accept at-least-once delivery.
|
||||
- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the scheduled processes on one node, or accept at-least-once delivery.
|
||||
- **Email threading headers.** `Message-ID` and `In-Reply-To` need core messaging support for custom headers, so they are not set.
|
||||
- **Recipient visibility.** It is checked through `createAutomatedSessionForUser`. If a host's authentication module can't build a session with permissions for a user, email goes out only for alerts that have no visibility permission and no subject table. In-app delivery is always filtered by the record locks.
|
||||
- **Outbound HTTP** blocks plain http, but has no full SSRF guard. Channels are admin-only. Core's `SafeHttpClient` (QRun-IO/qqq#1109) replaces the client when it is released.
|
||||
- **Alertmanager sink:** open alerts are sent with an `endsAt` a week out, not re-posted.
|
||||
- **Not built yet:** digests, quiet hours, reminders, Micrometer metrics, ROLE and IdP-group team resolvers, escalation, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
|
||||
- **Not built yet:** digests, quiet hours, Micrometer metrics, ROLE and IdP-group team resolvers, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
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:
|
||||
- **Timed notifications (#27, part 2):** reminders and escalation are implemented ahead of the remaining P4–P6 features. The minute job uses `stepOrder` and typed `userId` / `teamId` / `channelId` fields instead of the design's generic `targetId`. Policy description and enabled fields make the standard admin records usable. Types override groups; only OPEN, unacknowledged alerts run, even if a step is configured for another status, because acknowledgement must stop escalation.
|
||||
- **Escalation progress:** existing ESCALATED events hold level transitions and final-step repeat attempts; repeats keep the same level. Reopen events define each cycle's start and repeat budget. One step runs per minute, including when several steps became overdue. Null/zero repeat settings disable repeats. Suppression consumes an escalation attempt, as the normal router records why no delivery was queued.
|
||||
- **Timed notification transactions:** `AlertsDb` passes the current QQQ transaction to table actions. Each alert's timed fan-out, events and progress commit together. A read-only `timedNotificationCheckedAt` field prevents repeated evaluation in the same minute, including attempts with no eligible recipients. Alert and team scans use bounded pages. Runtime ticks are serialized locally; hosts must run the job on one scheduler node, matching the existing single-node scheduling guidance.
|
||||
- **Reminders and EXPIRED:** null reminder interval means 60 minutes for CRITICAL and off otherwise; explicit zero is off. Reminders use subscription event filters and NOTIFIED timeline entries, without introducing a REMINDER event. Record followers do not receive scheduled reminders without a matching subscription. EXPIRED remains reserved but is excluded from the resolution possible values; the design defines no expiration behavior. No ESCALATION feature switch is present in the current config.
|
||||
- **UI API and record following:** the RC.6 alerts extension, collaboration ownership of follow, and machine webhook migration are specified in [UI-API.md](UI-API.md). Existing RECORD watch subscriptions remain readable without migration.
|
||||
- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`.
|
||||
- **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.
|
||||
|
||||
@@ -22,6 +22,8 @@ import java.util.List;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
@@ -176,7 +178,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
*******************************************************************************/
|
||||
public static List<String> allTableNames()
|
||||
{
|
||||
return (List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsSubscription.TABLE_NAME,
|
||||
return (List.of(AlertsEscalationPolicy.TABLE_NAME, AlertsEscalationStep.TABLE_NAME, AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsSubscription.TABLE_NAME,
|
||||
AlertsUserPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME,
|
||||
AlertsDelivery.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsHeartbeat.TABLE_NAME, AlertsNotification.TABLE_NAME,
|
||||
AlertsNotificationRecipient.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME));
|
||||
|
||||
@@ -147,129 +147,8 @@ public class AlertRouter
|
||||
|
||||
try
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
List<Destination> destinations = destinations(target, severity, kind);
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
SuppressedReason reason = null;
|
||||
String channelType = destination.channel().getValueString("channelType");
|
||||
boolean inApp = ChannelType.IN_APP.name().equals(channelType);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
reason = SuppressedReason.USER_PAUSED;
|
||||
}
|
||||
else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled")))
|
||||
{
|
||||
reason = SuppressedReason.CHANNEL_DISABLED;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(isDuplicate(alert, destination, kind, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -279,6 +158,211 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A due reminder uses subscription event filters and the normal delivery checks.
|
||||
*******************************************************************************/
|
||||
public int remind(QRecord alert) throws QException
|
||||
{
|
||||
return (deliver(alert, null, null, DeliveryKind.REMINDER, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy target uses the same channel, permission and suppression checks.
|
||||
** Team members are read in bounded pages; no membership source is changed.
|
||||
*******************************************************************************/
|
||||
public void escalate(QRecord alert, QRecord step, Integer eventId) throws QException
|
||||
{
|
||||
String targetType = step.getValueString("targetType");
|
||||
if(SubscriberType.CHANNEL.name().equals(targetType))
|
||||
{
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, step.getValue("channelId"));
|
||||
if(channel != null)
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(null, channel, null, null, null)));
|
||||
}
|
||||
}
|
||||
else if(SubscriberType.USER.name().equals(targetType))
|
||||
{
|
||||
escalateToUser(alert, step.getValueString("userId"), eventId);
|
||||
}
|
||||
else if(SubscriberType.TEAM.name().equals(targetType))
|
||||
{
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> members = AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", step.getValue("teamId"))
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", true)).withLimit(100));
|
||||
if(members.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord member : members)
|
||||
{
|
||||
escalateToUser(alert, member.getValueString("userId"), eventId);
|
||||
lastId = member.getValueInteger("id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User and team targets use the user's normal destinations.
|
||||
*******************************************************************************/
|
||||
private void escalateToUser(QRecord alert, String userId, Integer eventId) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(null, preference))
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(userId, channel, null, null, preference)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared fan-out. Scheduled callers propagate failures so their transaction rolls back.
|
||||
*******************************************************************************/
|
||||
private int deliver(QRecord alert, EventType eventType, Integer eventId, DeliveryKind kind, List<Destination> destinations) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
SuppressedReason reason = null;
|
||||
String channelType = destination.channel().getValueString("channelType");
|
||||
boolean inApp = ChannelType.IN_APP.name().equals(channelType);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
reason = SuppressedReason.USER_PAUSED;
|
||||
}
|
||||
else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled")))
|
||||
{
|
||||
reason = SuppressedReason.CHANNEL_DISABLED;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(kind != DeliveryKind.REMINDER && isDuplicate(alert, destination, kind, eventId, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
return (queued);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an active SUPPRESS_ALERTS maintenance window covers the target.
|
||||
*******************************************************************************/
|
||||
@@ -366,7 +450,7 @@ public class AlertRouter
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String recordKey : target.recordKeys())
|
||||
for(String recordKey : kind == DeliveryKind.REMINDER ? java.util.Set.<String>of() : target.recordKeys())
|
||||
{
|
||||
String[] parts = recordKey.split(":", 2);
|
||||
RecordReference reference = new RecordReference(parts[0], parts[1]);
|
||||
@@ -684,13 +768,20 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** The same alert, channel, recipient and kind within 10 minutes.
|
||||
*******************************************************************************/
|
||||
private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, Instant now) throws QException
|
||||
private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, Integer eventId, Instant now) throws QException
|
||||
{
|
||||
var filter = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("channelId", QCriteriaOperator.EQUALS, destination.channel().getValue("id"))
|
||||
.withCriteria("kind", QCriteriaOperator.EQUALS, kind.name())
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.SENDING.name(), DeliveryStatus.SENT.name(), DeliveryStatus.RETRYING.name()))
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN, now.minus(DUPLICATE_WINDOW_MINUTES, ChronoUnit.MINUTES));
|
||||
if(kind == DeliveryKind.ESCALATED)
|
||||
{
|
||||
filter = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("channelId", QCriteriaOperator.EQUALS, destination.channel().getValue("id"))
|
||||
.withCriteria("alertEventId", QCriteriaOperator.EQUALS, eventId)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name());
|
||||
}
|
||||
if(destination.userId() != null)
|
||||
{
|
||||
filter.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, destination.userId());
|
||||
|
||||
@@ -23,11 +23,14 @@ 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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.QStatusMessage;
|
||||
@@ -56,7 +59,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (QueryAction.execute(tableName, filter));
|
||||
return (new QueryAction().execute(new QueryInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getRecords());
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +69,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord first(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> records = QueryAction.execute(tableName, filter.withLimit(1));
|
||||
List<QRecord> records = query(tableName, filter.withLimit(1));
|
||||
return (CollectionUtils.nullSafeIsEmpty(records) ? null : records.get(0));
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static int count(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = CountAction.execute(tableName, filter);
|
||||
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String tableName, QRecord record) throws QException
|
||||
{
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record));
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
|
||||
QRecord result = inserted.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
@@ -115,7 +118,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord update(String tableName, QRecord record) throws QException
|
||||
{
|
||||
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record));
|
||||
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
|
||||
QRecord result = updated.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
|
||||
@@ -568,6 +568,8 @@ public class AlertsEngine
|
||||
.withValue("severity", severity.name())
|
||||
.withValue("lastRaisedAt", now)
|
||||
.withValue("occurrenceCount", (resolved.getValueInteger("occurrenceCount") == null ? 0 : resolved.getValueInteger("occurrenceCount")) + 1)
|
||||
.withValue("escalationLevel", 0)
|
||||
.withValue("timedNotificationCheckedAt", null)
|
||||
.withValue("reopenCount", (resolved.getValueInteger("reopenCount") == null ? 0 : resolved.getValueInteger("reopenCount")) + 1)
|
||||
.withValue("resolvedAt", null)
|
||||
.withValue("resolvedByUserId", null)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notifications for open, unacknowledged alerts. Progress and fan-out
|
||||
** commit together per alert. Run on one scheduler node, like the other jobs.
|
||||
*******************************************************************************/
|
||||
public class EscalationAndReminder
|
||||
{
|
||||
private static final int PAGE_SIZE = 100;
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the installed runtime, including its controllable clock.
|
||||
*******************************************************************************/
|
||||
public EscalationAndReminder(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read bounded pages. A persisted checkpoint makes repeated ticks harmless,
|
||||
** including attempts with no eligible recipients. Serialize this runtime's ticks.
|
||||
*******************************************************************************/
|
||||
public int runDue() throws QException
|
||||
{
|
||||
synchronized(runtime)
|
||||
{
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
int changed = 0;
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> alerts = AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("status", Status.OPEN.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(PAGE_SIZE));
|
||||
if(alerts.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord alert : alerts)
|
||||
{
|
||||
lastId = alert.getValueInteger("id");
|
||||
changed += process(lastId);
|
||||
}
|
||||
}
|
||||
return (changed);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One alert is an atomic unit, including in-app recipients and delivery rows.
|
||||
*******************************************************************************/
|
||||
private int process(Integer alertId) throws QException
|
||||
{
|
||||
QBackendTransaction previous = QContext.getQBackendTransaction();
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(AlertsAlert.TABLE_NAME)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
int changed = apply(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), runtime.now());
|
||||
transaction.commit();
|
||||
return (changed);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (new QException("Could not apply timed notifications for alert " + alertId, e));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledge, snooze, resolve, either mute effect, and disabled types stop work.
|
||||
*******************************************************************************/
|
||||
private int apply(QRecord alert, Instant now) throws QException
|
||||
{
|
||||
if(alert == null || !Status.OPEN.name().equals(alert.getValueString("status")) || alert.getValue("acknowledgedAt") != null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
Instant checked = alert.getValueInstant("timedNotificationCheckedAt");
|
||||
if(checked != null && !checked.truncatedTo(ChronoUnit.MINUTES).isBefore(now.truncatedTo(ChronoUnit.MINUTES)))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
if(type == null || Boolean.FALSE.equals(type.getValueBoolean("enabled")) || Boolean.FALSE.equals(type.getValueBoolean("notify")))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
AlertsEngine.AlertTarget target = AlertsEngine.AlertTarget.of(alert);
|
||||
if(Boolean.TRUE.equals(alert.getValueBoolean("muted")) || Boolean.TRUE.equals(alert.getValueBoolean("flapping"))
|
||||
|| runtime.getRouter().isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS)
|
||||
|| runtime.getRouter().isSuppressedByMaintenance(target, now))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
int changed = escalate(alert, type, now) ? 1 : 0;
|
||||
QRecord fresh = AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id"));
|
||||
Integer interval = type.getValueInteger("reminderIntervalMinutes");
|
||||
if(interval == null && "CRITICAL".equals(fresh.getValueString("severity")))
|
||||
{
|
||||
interval = 60;
|
||||
}
|
||||
Instant raised = latest(fresh.getValueInstant("lastRaisedAt"), fresh.getValueInstant("firstRaisedAt"));
|
||||
Instant since = latest(fresh.getValueInstant("lastNotifiedAt"), raised);
|
||||
if(interval != null && interval > 0 && since != null && !since.plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
if(runtime.getRouter().remind(fresh) > 0)
|
||||
{
|
||||
changed = 1;
|
||||
}
|
||||
}
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("timedNotificationCheckedAt", now));
|
||||
return (changed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Type policy wins; otherwise use the alert's group policy. Steps are ordered
|
||||
** by stepOrder and measured from the most recent reopen (else first raise).
|
||||
** At most one step is taken per minute; missed steps are not burst-delivered.
|
||||
*******************************************************************************/
|
||||
private boolean escalate(QRecord alert, QRecord type, Instant now) throws QException
|
||||
{
|
||||
Integer policyId = type.getValueInteger("escalationPolicyId");
|
||||
if(policyId == null)
|
||||
{
|
||||
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, alert.getValue("groupId"));
|
||||
policyId = group == null ? null : group.getValueInteger("escalationPolicyId");
|
||||
}
|
||||
QRecord policy = AlertsDb.byId(AlertsEscalationPolicy.TABLE_NAME, policyId);
|
||||
if(policy == null || !Boolean.TRUE.equals(policy.getValueBoolean("enabled")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord reopened = AlertsDb.first(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.REOPENED.name()).withOrderBy(new QFilterOrderBy("id", false)));
|
||||
Instant start = reopened == null ? alert.getValueInstant("firstRaisedAt") : reopened.getValueInstant("at");
|
||||
int level = alert.getValueInteger("escalationLevel") == null ? 0 : alert.getValueInteger("escalationLevel");
|
||||
QRecord step = AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policyId)
|
||||
.withCriteria("stepOrder", QCriteriaOperator.GREATER_THAN, level).withOrderBy(new QFilterOrderBy("stepOrder", true)));
|
||||
if(step == null)
|
||||
{
|
||||
step = repeatStep(alert, policy, reopened, level, now);
|
||||
}
|
||||
if(step == null || start == null || step.getValueInteger("afterMinutes") == null || step.getValueInteger("afterMinutes") < 0
|
||||
|| !Status.OPEN.name().equals(step.getValueString("onlyIfStatus"))
|
||||
|| start.plus(step.getValueInteger("afterMinutes"), ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int nextLevel = step.getValueInteger("stepOrder");
|
||||
Integer eventId = runtime.getEngine().event(alert.getValueInteger("id"), EventType.ESCALATED, null,
|
||||
String.valueOf(level), String.valueOf(nextLevel), level == nextLevel ? "Repeated final escalation step" : "Escalation step " + nextLevel, now);
|
||||
runtime.getRouter().escalate(alert, step, eventId);
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("escalationLevel", nextLevel));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeat only the final step, capped by the number of same-level events in
|
||||
** this open cycle. Repeat spacing uses escalation time, never reminder time.
|
||||
*******************************************************************************/
|
||||
private QRecord repeatStep(QRecord alert, QRecord policy, QRecord reopened, int level, Instant now) throws QException
|
||||
{
|
||||
Integer interval = policy.getValueInteger("repeatAfterMinutes");
|
||||
Integer maxRepeats = policy.getValueInteger("maxRepeats");
|
||||
if(level == 0 || interval == null || interval <= 0 || maxRepeats == null || maxRepeats <= 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QQueryFilter cycle = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"));
|
||||
QRecord last = AlertsDb.first(AlertsEvent.TABLE_NAME, cycle.withOrderBy(new QFilterOrderBy("id", false)));
|
||||
if(last == null || last.getValueInstant("at").plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
int repeats = AlertsDb.count(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"))
|
||||
.withCriteria("fromValue", QCriteriaOperator.EQUALS, String.valueOf(level))
|
||||
.withCriteria("toValue", QCriteriaOperator.EQUALS, String.valueOf(level)));
|
||||
return (repeats >= maxRepeats ? null : AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policy.getValue("id"))
|
||||
.withCriteria("stepOrder", QCriteriaOperator.EQUALS, level)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Later of two optional timestamps.
|
||||
*******************************************************************************/
|
||||
private static Instant latest(Instant left, Instant right)
|
||||
{
|
||||
return (left == null ? right : right == null || left.isAfter(right) ? left : right);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
@@ -55,7 +56,7 @@ public class AlertsAppMetaDataProducer extends QBitComponentMetaDataProducer<QAp
|
||||
{
|
||||
/** the main tables, in nav order. */
|
||||
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsEscalationPolicy.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -46,13 +46,15 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
** - the record and table actions (AlertsAction), each a standard streamed
|
||||
** ETL process on its table: optional input, Review summary lines, Result
|
||||
** summary lines;
|
||||
** - two hidden scheduled processes: alertsEvaluateMonitors (monitors, plus
|
||||
** snooze / quiet-period / flapping upkeep) and alertsDeliverNotifications.
|
||||
** - hidden scheduled processes: alertsEvaluateMonitors (monitors, plus
|
||||
** snooze / quiet-period / flapping upkeep), alertsDeliverNotifications,
|
||||
** and alertsEscalateAndRemind.
|
||||
** They exist without a scheduler and run on one only when the config names
|
||||
** it.
|
||||
*******************************************************************************/
|
||||
public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
|
||||
{
|
||||
public static final String ESCALATE_AND_REMIND = "alertsEscalateAndRemind";
|
||||
public static final String EVALUATE_MONITORS = "alertsEvaluateMonitors";
|
||||
public static final String DELIVER_NOTIFICATIONS = "alertsDeliverNotifications";
|
||||
|
||||
@@ -87,6 +89,7 @@ public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
}
|
||||
processes.add(scheduled(config, EVALUATE_MONITORS, "Evaluate alert monitors", AlertsScheduledSteps.EvaluateMonitors.class, config.getEvaluateEverySeconds()));
|
||||
processes.add(scheduled(config, DELIVER_NOTIFICATIONS, "Deliver alert notifications", AlertsScheduledSteps.DeliverNotifications.class, config.getDeliverEverySeconds()));
|
||||
processes.add(scheduled(config, ESCALATE_AND_REMIND, "Escalate and remind", AlertsScheduledSteps.EscalateAndRemind.class, 60));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsPermissionChecker;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
|
||||
@@ -29,6 +30,8 @@ import com.kingsrook.qbits.alerts.customizers.AlertsWriteGuardCustomizer;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
@@ -95,6 +98,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
public static final String JOIN_POLICY_STEP = "alerts_escalation_policy_join_alerts_escalation_step";
|
||||
public static final String JOIN_ALERT_EVENT = "alerts_alert_join_alerts_event";
|
||||
public static final String JOIN_ALERT_RECORD_REF = "alerts_alert_join_alerts_record_ref";
|
||||
public static final String JOIN_ALERT_DELIVERY = "alerts_alert_join_alerts_delivery";
|
||||
@@ -135,7 +139,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(widgetSection(JOIN_ALERT_RECORD_REF, "Records", "link"))
|
||||
.withSection(widgetSection(JOIN_ALERT_DELIVERY, "Notifications", "send"))
|
||||
.withSection(new QFieldSection("occurrence", "Occurrences", new QIcon("repeat"), Tier.T3, List.of("firstRaisedAt", "lastRaisedAt", "occurrenceCount", "reopenCount", "flapping",
|
||||
"lastNotifiedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
"lastNotifiedAt", "timedNotificationCheckedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
return (readOnly(table));
|
||||
}
|
||||
|
||||
@@ -154,7 +158,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "groupId", "sourceName", "openCount")))
|
||||
.withSection(new QFieldSection("about", "About", new QIcon("info"), Tier.T2, List.of("description", "howToFix", "subjectTableName", "visibilityPermission")))
|
||||
.withSection(new QFieldSection("tuning", "Tuning", new QIcon("tune"), Tier.T2, List.of("enabled", "notify", "defaultSeverity", "severityOverride", "resolveMode",
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "externalDetailCeiling")))
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "escalationPolicyId", "externalDetailCeiling")))
|
||||
.withSection(widgetSection(JOIN_TYPE_MONITOR, "Monitors", "monitor_heart"))
|
||||
.withSection(widgetSection(JOIN_TYPE_ALERT, "Alerts", "notifications_active"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("installed", "lastRaisedAt", "createDate", "modifyDate")));
|
||||
@@ -175,7 +179,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "parentGroupId")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "icon", "sortOrder")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "escalationPolicyId", "icon", "sortOrder")))
|
||||
.withSection(widgetSection(JOIN_GROUP_TYPE, "Alert Types", "category"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
return (table);
|
||||
@@ -506,12 +510,52 @@ public final class AlertsTableMetaDataHelper
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation policies, with ordered steps on the record screen.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationPolicyTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, AlertsEscalationPolicy.TABLE_NAME, "Escalation Policies", "trending_up", AlertsEscalationPolicy.class), "description")
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "repeatAfterMinutes", "maxRepeats")))
|
||||
.withSection(widgetSection(JOIN_POLICY_STEP, "Steps", "trending_up"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
table.getField("maxRepeats").setDefaultValue(0);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation steps are standard admin tables, reached from their policy.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationStepTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsEscalationStep.TABLE_NAME, "Escalation Steps", "trending_up", AlertsEscalationStep.class)
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("Step %s").withRecordLabelFields("stepOrder")
|
||||
.withUniqueKey(new UniqueKey("policyId", "stepOrder"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "policyId", "stepOrder", "afterMinutes")))
|
||||
.withSection(new QFieldSection("target", new QIcon("person"), Tier.T2, List.of("targetType", "userId", "teamId", "channelId", "onlyIfStatus")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("onlyIfStatus").setDefaultValue(AlertsPossibleValues.Status.OPEN.name());
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> buildAllTables(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
return (List.of(buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
return (List.of(buildEscalationPolicyTable(config), buildEscalationStepTable(config), buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
buildUserPreferenceTable(config), buildTeamTable(config), buildTeamMemberTable(config), buildChannelTable(config), buildMuteTable(config),
|
||||
buildDeliveryTable(config), buildEventTable(config), buildRecordRefTable(config), buildHeartbeatTable(config), buildNotificationTable(config),
|
||||
buildNotificationRecipientTable(config), buildNotificationPreferenceTable(config)));
|
||||
@@ -525,7 +569,7 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QPossibleValueSource> buildPossibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>(AlertsPossibleValues.buildEnumSources());
|
||||
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
for(String table : List.of(AlertsEscalationPolicy.TABLE_NAME, AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME, AlertsNotification.TABLE_NAME))
|
||||
{
|
||||
sources.add(QPossibleValueSource.newForTable(table));
|
||||
@@ -541,6 +585,7 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QJoinMetaData> buildJoins()
|
||||
{
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
joins.add(oneToMany(JOIN_POLICY_STEP, AlertsEscalationPolicy.TABLE_NAME, AlertsEscalationStep.TABLE_NAME, "policyId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_EVENT, AlertsAlert.TABLE_NAME, AlertsEvent.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_RECORD_REF, AlertsAlert.TABLE_NAME, AlertsRecordRef.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_DELIVERY, AlertsAlert.TABLE_NAME, AlertsDelivery.TABLE_NAME, "alertId"));
|
||||
@@ -569,6 +614,9 @@ public final class AlertsTableMetaDataHelper
|
||||
}
|
||||
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_POLICY_STEP)).withLabel("Steps").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("stepOrder", true)))
|
||||
.withOnlyIncludeFieldNames(List.of("stepOrder", "afterMinutes", "targetType", "onlyIfStatus")).getWidgetMetaData());
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_EVENT), "Timeline", 50, new QFilterOrderBy("at", false), List.of("at", "eventType", "userId", "toValue", "note")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_RECORD_REF), "Records", 25, new QFilterOrderBy("id", true), List.of("tableName", "recordId", "label", "active")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
|
||||
@@ -147,6 +147,9 @@ public class AlertsAlert extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer escalationLevel;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant timedNotificationCheckedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -1380,4 +1383,35 @@ public class AlertsAlert extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public Instant getTimedNotificationCheckedAt()
|
||||
{
|
||||
return (this.timedNotificationCheckedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public void setTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public AlertsAlert withTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notification steps for an unacknowledged alert.
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationPolicy extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_policy";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private String description;
|
||||
|
||||
@QField()
|
||||
private Integer repeatAfterMinutes;
|
||||
|
||||
@QField()
|
||||
private Integer maxRepeats;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getRepeatAfterMinutes()
|
||||
{
|
||||
return (this.repeatAfterMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public void setRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public Integer getMaxRepeats()
|
||||
{
|
||||
return (this.maxRepeats);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public void setMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ordered destination in an escalation policy.
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationStep extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_step";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer policyId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer stepOrder;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer afterMinutes;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_subscriber_type")
|
||||
private String targetType;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String userId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_team")
|
||||
private Integer teamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_channel")
|
||||
private Integer channelId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_status")
|
||||
private String onlyIfStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyId
|
||||
*******************************************************************************/
|
||||
public Integer getPolicyId()
|
||||
{
|
||||
return (this.policyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for policyId
|
||||
*******************************************************************************/
|
||||
public void setPolicyId(Integer policyId)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withPolicyId(Integer policyId)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for stepOrder
|
||||
*******************************************************************************/
|
||||
public Integer getStepOrder()
|
||||
{
|
||||
return (this.stepOrder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for stepOrder
|
||||
*******************************************************************************/
|
||||
public void setStepOrder(Integer stepOrder)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for stepOrder
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withStepOrder(Integer stepOrder)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getAfterMinutes()
|
||||
{
|
||||
return (this.afterMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public void setAfterMinutes(Integer afterMinutes)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withAfterMinutes(Integer afterMinutes)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetType
|
||||
*******************************************************************************/
|
||||
public String getTargetType()
|
||||
{
|
||||
return (this.targetType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for targetType
|
||||
*******************************************************************************/
|
||||
public void setTargetType(String targetType)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetType
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTargetType(String targetType)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for teamId
|
||||
*******************************************************************************/
|
||||
public Integer getTeamId()
|
||||
{
|
||||
return (this.teamId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for teamId
|
||||
*******************************************************************************/
|
||||
public void setTeamId(Integer teamId)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for teamId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTeamId(Integer teamId)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channelId
|
||||
*******************************************************************************/
|
||||
public Integer getChannelId()
|
||||
{
|
||||
return (this.channelId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channelId
|
||||
*******************************************************************************/
|
||||
public void setChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channelId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public String getOnlyIfStatus()
|
||||
{
|
||||
return (this.onlyIfStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public void setOnlyIfStatus(String onlyIfStatus)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withOnlyIfStatus(String onlyIfStatus)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ public class AlertsGroup extends QRecordEntity
|
||||
@QField(possibleValueSourceName = "alerts_team", label = "Owner Team")
|
||||
private Integer ownerTeamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -394,4 +397,35 @@ public class AlertsGroup extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsGroup withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ public final class AlertsPossibleValues
|
||||
enumSource(MONITOR_KIND, AlertMonitorKind.values()),
|
||||
enumSource(MONITOR_STATUS, MonitorStatus.values()),
|
||||
enumSource(EVENT_TYPE, EventType.values()),
|
||||
enumSource(RESOLUTION, Resolution.values()),
|
||||
enumSource(RESOLUTION, new Resolution[] { Resolution.PRODUCER, Resolution.MONITOR, Resolution.QUIET_PERIOD, Resolution.MANUAL }),
|
||||
enumSource(SUBSCRIBER_TYPE, SubscriberType.values()),
|
||||
enumSource(SCOPE_TYPE, ScopeType.values()),
|
||||
enumSource(DELIVERY_MODE, DeliveryMode.values()),
|
||||
|
||||
@@ -96,6 +96,9 @@ public class AlertsType extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer openCount;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -802,4 +805,35 @@ public class AlertsType extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsType withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.alerts.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.EscalationAndReminder;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationEmailer;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -80,4 +81,21 @@ public final class AlertsScheduledSteps
|
||||
output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alertsEscalateAndRemind
|
||||
*******************************************************************************/
|
||||
public static class EscalateAndRemind implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Run timed notifications using the installed runtime's clock.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue(OUTPUT_COUNT, new EscalationAndReminder(AlertsRuntime.get()).runDue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,3 +439,57 @@ ALTER TABLE alerts_mute ADD COLUMN scope_record_id VARCHAR(100);
|
||||
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_value' AND CHARACTER_MAXIMUM_LENGTH < 520
|
||||
-- comment: ALERT mutes hold the complete type and dedup key, including long keys.
|
||||
ALTER TABLE alerts_mute ALTER COLUMN scope_value SET DATA TYPE VARCHAR(520);
|
||||
|
||||
-- changeset qbit-alerts:23
|
||||
-- comment: Admin escalation policies and their ordered destinations.
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_after_minutes INTEGER,
|
||||
max_repeats INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_policy PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_policy UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_step (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
policy_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
after_minutes INTEGER NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
user_id VARCHAR(100),
|
||||
team_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
only_if_status VARCHAR(16) DEFAULT 'OPEN' NOT NULL,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_step PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_step UNIQUE (policy_id, step_order),
|
||||
CONSTRAINT ck_alerts_escalation_step_order CHECK (step_order > 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_minutes CHECK (after_minutes >= 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_target CHECK (
|
||||
(target_type = 'USER' AND user_id IS NOT NULL AND team_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'TEAM' AND team_id IS NOT NULL AND user_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'CHANNEL' AND channel_id IS NOT NULL AND user_id IS NULL AND team_id IS NULL))
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:24
|
||||
-- comment: Type policies override the group's policy.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_type ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:25
|
||||
-- comment: A group policy is used when the type has none.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_group' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_group ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:26
|
||||
-- comment: Persist the timed job checkpoint, including attempts without recipients.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_alert' AND LOWER(column_name) = 'timed_notification_checked_at'
|
||||
ALTER TABLE alerts_alert ADD COLUMN timed_notification_checked_at TIMESTAMP;
|
||||
@@ -294,4 +294,37 @@ class AlertsQBitProducerTest extends BaseAlertsTest
|
||||
assertThat(AlertsIntegrations.esbTableNames()).containsExactly("alerts_alert", "alerts_monitor", "alerts_type");
|
||||
assertThat(AlertsIntegrations.quickSearchTables()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy records are admin-only, steps are children, and the job is hidden.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEscalationMetadata() throws Exception
|
||||
{
|
||||
assertThat(AlertsAppMetaDataProducer.MAIN_TABLES).contains("alerts_escalation_policy").doesNotContain("alerts_escalation_step");
|
||||
assertThat(qInstance.getTable("alerts_escalation_policy").getSections()).anySatisfy(section ->
|
||||
assertThat(section.getWidgetName()).isEqualTo("alerts_escalation_policy_join_alerts_escalation_step"));
|
||||
assertThat(qInstance.getTable("alerts_type").getField("escalationPolicyId").getPossibleValueSourceName()).isEqualTo("alerts_escalation_policy");
|
||||
assertThat(qInstance.getTable("alerts_group").getField("escalationPolicyId").getPossibleValueSourceName()).isEqualTo("alerts_escalation_policy");
|
||||
assertThat(qInstance.getTable("alerts_escalation_step").getField("onlyIfStatus").getDefaultValue()).isEqualTo("OPEN");
|
||||
assertThat(qInstance.getPossibleValueSource("alerts_resolution").getEnumValues()).extracting(value -> String.valueOf(value.getId())).doesNotContain("EXPIRED");
|
||||
QProcessMetaData process = qInstance.getProcess(AlertsProcessesMetaDataProducer.ESCALATE_AND_REMIND);
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
assertThat(process.getSchedule()).isNull();
|
||||
QProcessMetaData scheduled = AlertsProcessesMetaDataProducer.buildProcesses(new AlertsQBitConfig().withSchedulerName("scheduler"))
|
||||
.stream().filter(candidate -> candidate.getName().equals(AlertsProcessesMetaDataProducer.ESCALATE_AND_REMIND)).findFirst().orElseThrow();
|
||||
assertThat(scheduled.getSchedule().getSchedulerName()).isEqualTo("scheduler");
|
||||
assertThat(scheduled.getSchedule().getRepeatSeconds()).isEqualTo(60);
|
||||
loginAs(VIEWER);
|
||||
for(String table : List.of("alerts_escalation_policy", "alerts_escalation_step"))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput(), table,
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType.READ)).isFalse();
|
||||
}
|
||||
loginAs(ADMIN);
|
||||
assertThat(PermissionsHelper.hasTablePermission(new com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput(), "alerts_escalation_policy",
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType.INSERT)).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
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.processes.AlertsScheduledSteps;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
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.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notifications against the real H2 tables and router, with a movable clock.
|
||||
*******************************************************************************/
|
||||
class EscalationAndReminderTest extends BaseAlertsTest
|
||||
{
|
||||
private static final String TYPE = "test.timed";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normal declared alert type with a group.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeInstance(QInstance instance)
|
||||
{
|
||||
AlertsInstanceMetaData.ofOrWithNew(instance).withAlertType(new QAlertTypeMetaData()
|
||||
.withName(TYPE).withLabel("Timed alert").withGroupName("timed").withDefaultSeverity(AlertSeverity.ERROR));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise and tune the reminder interval without initial notifications.
|
||||
*******************************************************************************/
|
||||
private QRecord alert(Integer interval, AlertSeverity severity) throws QException
|
||||
{
|
||||
QRecord alert = runtime.getEngine().raise(new AlertSignal(TYPE).withSeverity(severity));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("reminderIntervalMinutes", interval));
|
||||
return (alert);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store changes as the system, like the engine.
|
||||
*******************************************************************************/
|
||||
private void update(String table, QRecord record) throws QException
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.update(table, record));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a test configuration row.
|
||||
*******************************************************************************/
|
||||
private QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.insert(table, record)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One subscription with an explicit event filter.
|
||||
*******************************************************************************/
|
||||
private void subscribe(String user, String events) throws QException
|
||||
{
|
||||
insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", user)
|
||||
.withValue("scopeType", "ALL").withValue("events", events).withValue("enabled", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy attached to this alert's type.
|
||||
*******************************************************************************/
|
||||
private QRecord policy(QRecord alert, int repeatMinutes, int maxRepeats) throws QException
|
||||
{
|
||||
QRecord policy = insert(AlertsEscalationPolicy.TABLE_NAME, new QRecord().withValue("name", "On call")
|
||||
.withValue("enabled", true).withValue("repeatAfterMinutes", repeatMinutes).withValue("maxRepeats", maxRepeats));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", policy.getValue("id")));
|
||||
return (policy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An explicit ordered target.
|
||||
*******************************************************************************/
|
||||
private QRecord step(QRecord policy, int order, int minutes, String targetType, String targetField, java.io.Serializable targetId) throws QException
|
||||
{
|
||||
return (insert(AlertsEscalationStep.TABLE_NAME, new QRecord().withValue("policyId", policy.getValue("id"))
|
||||
.withValue("stepOrder", order).withValue("afterMinutes", minutes).withValue("targetType", targetType)
|
||||
.withValue(targetField, targetId).withValue("onlyIfStatus", "OPEN")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tick through the real hidden backend step.
|
||||
*******************************************************************************/
|
||||
private void tick() throws QException
|
||||
{
|
||||
new AlertsScheduledSteps.EscalateAndRemind().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a refreshed alert.
|
||||
*******************************************************************************/
|
||||
private QRecord fresh(QRecord alert) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Due boundary, event filter, NOTIFIED timeline and same-minute idempotency.
|
||||
** A one-minute interval also proves reminders bypass the ten-minute duplicate window.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderDueAndNotDue() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
subscribe("viewer-reminders", "REMINDER");
|
||||
subscribe("viewer-raised", "RAISED");
|
||||
clock.advance(Duration.ofSeconds(59));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("kind")).isEqualTo("REMINDER");
|
||||
assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-reminders");
|
||||
assertThat(row.getValueString("status")).isEqualTo("SENT");
|
||||
});
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isEqualTo(clock.instant());
|
||||
assertThat(all(AlertsEvent.TABLE_NAME)).extracting(row -> row.getValueString("eventType")).containsExactly("RAISED", "NOTIFIED");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The newer of last raise and last successful notification sets the due time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderUsesLatestRaiseOrNotification() throws Exception
|
||||
{
|
||||
QRecord alert = alert(10, AlertSeverity.ERROR);
|
||||
subscribe("viewer-reminders", "REMINDER");
|
||||
clock.advance(Duration.ofMinutes(8));
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE));
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofMinutes(8));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(9));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isEqualTo(clock.instant());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRITICAL has a 60-minute default; explicit zero still disables reminders.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCriticalDefaultAndExplicitOff() throws Exception
|
||||
{
|
||||
QRecord alert = alert(null, AlertSeverity.CRITICAL);
|
||||
subscribe("viewer", "REMINDER");
|
||||
clock.advance(Duration.ofMinutes(59));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("reminderIntervalMinutes", 0));
|
||||
clock.advance(Duration.ofHours(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null is off for non-critical alerts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderDefaultOff() throws Exception
|
||||
{
|
||||
alert(null, AlertSeverity.ERROR);
|
||||
subscribe("viewer", "REMINDER");
|
||||
clock.advance(Duration.ofDays(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every stopping condition blocks both kinds of timed notification.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"ACKNOWLEDGED", "SNOOZED", "RESOLVED", "muted", "disabled", "silent", "flapping", "SILENCE_NOTIFICATIONS", "SUPPRESS_ALERTS"})
|
||||
void testStops(String condition) throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 2);
|
||||
step(policy, 1, 1, "USER", "userId", "viewer");
|
||||
subscribe("viewer", "REMINDER");
|
||||
switch(condition)
|
||||
{
|
||||
case "ACKNOWLEDGED" -> runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
|
||||
case "SNOOZED" -> runtime.getEngine().snooze(alert.getValueInteger("id"), clock.instant().plusSeconds(3600), ADMIN);
|
||||
case "RESOLVED" -> runtime.getEngine().resolveById(alert.getValueInteger("id"), Resolution.MANUAL, ADMIN, "Done");
|
||||
case "disabled", "silent" -> update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId"))
|
||||
.withValue("disabled".equals(condition) ? "enabled" : "notify", false));
|
||||
case "muted", "flapping" -> update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue(condition, true));
|
||||
default -> insert(AlertsMute.TABLE_NAME, new QRecord().withValue("name", "Maintenance").withValue("scopeType", "ALL")
|
||||
.withValue("enabled", true).withValue("effect", condition).withValue("startsAt", clock.instant()).withValue("endsAt", clock.instant().plusSeconds(3600)));
|
||||
}
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequential steps and capped repeats, with the same target inside ten minutes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStepsRepeatsAndIdempotency() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 2, 2);
|
||||
step(policy, 1, 1, "USER", "userId", "viewer");
|
||||
step(policy, 2, 3, "USER", "userId", "viewer");
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
clock.advance(Duration.ofDays(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(4).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(row.getValueString("kind")).isEqualTo("ESCALATED");
|
||||
});
|
||||
List<QRecord> events = where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "ESCALATED"));
|
||||
assertThat(events).extracting(row -> row.getValueString("fromValue")).containsExactly("0", "1", "2", "2");
|
||||
assertThat(events).extracting(row -> row.getValueString("toValue")).containsExactly("1", "2", "2", "2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledging after step one stops the next step and reminders.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAcknowledgeAfterFirstStep() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 5);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
step(policy, 2, 1, "USER", "userId", "viewer");
|
||||
subscribe("viewer", "REMINDER");
|
||||
tick();
|
||||
runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
|
||||
clock.advance(Duration.ofHours(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Team expansion uses members; channel targets enqueue an external delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTeamAndChannelTargets() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
QRecord team = insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "oncall").withValue("label", "On call"));
|
||||
for(String user : List.of("viewer-one", "viewer-two"))
|
||||
{
|
||||
insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", team.getValue("id")).withValue("userId", user));
|
||||
}
|
||||
QRecord channel = insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat")
|
||||
.withValue("channelType", "SLACK").withValue("enabled", true));
|
||||
step(policy, 1, 0, "TEAM", "teamId", team.getValueInteger("id"));
|
||||
step(policy, 2, 1, "CHANNEL", "channelId", channel.getValueInteger("id"));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly("viewer-one", "viewer-two");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id")))).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("kind")).isEqualTo("ESCALATED");
|
||||
assertThat(row.getValueString("status")).isEqualTo("PENDING");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group policy applies only when a type has no override; disabled overrides stop it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testGroupFallbackAndDisabledPolicy() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", alert.getValue("groupId")).withValue("escalationPolicyId", policy.getValue("id")));
|
||||
QRecord disabled = insert(AlertsEscalationPolicy.TABLE_NAME, new QRecord().withValue("name", "Disabled").withValue("enabled", false));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", disabled.getValue("id")));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", null));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated raises do not postpone escalation; reopening starts a fresh cycle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReopenRestartsStepsAndRepeatBudget() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 1);
|
||||
step(policy, 1, 5, "USER", "userId", "viewer");
|
||||
clock.advance(Duration.ofMinutes(4));
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
runtime.getEngine().resolveById(alert.getValueInteger("id"), Resolution.MANUAL, ADMIN, "Done");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
runtime.getEngine().reopenById(alert.getValueInteger("id"), ADMIN, "Back");
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(4));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(4);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An incompatible status never escalates, even though the alert is open.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOnlyIfStatus() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
QRecord step = step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
update(AlertsEscalationStep.TABLE_NAME, new QRecord().withValue("id", step.getValue("id")).withValue("onlyIfStatus", "ACKNOWLEDGED"));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Routing failures roll back the event, level, deliveries and minute checkpoint.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRollbackAndRetry() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
QCodeReference original = qInstance.getTable(AlertsDelivery.TABLE_NAME).getCustomizers().put(TableCustomizers.PRE_INSERT_RECORD.getRole(), new QCodeReference(RejectDelivery.class));
|
||||
assertThatThrownBy(this::tick).isInstanceOf(QException.class);
|
||||
assertThat(where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "ESCALATED"))).isEmpty();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
assertThat(fresh(alert).getValueInstant("timedNotificationCheckedAt")).isNull();
|
||||
qInstance.getTable(AlertsDelivery.TABLE_NAME).getCustomizers().put(TableCustomizers.PRE_INSERT_RECORD.getRole(), original);
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppressed attempts do not repeat within a minute or claim a successful send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSuppressedReminderCheckpoint() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord channel = insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "disabled-chat").withValue("label", "Chat")
|
||||
.withValue("channelType", "SLACK").withValue("enabled", false));
|
||||
insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "CHANNEL").withValue("channelId", channel.getValue("id"))
|
||||
.withValue("scopeType", "ALL").withValue("events", "REMINDER").withValue("enabled", true));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("status")).isEqualTo("SUPPRESSED");
|
||||
assertThat(row.getValueString("suppressedReason")).isEqualTo("CHANNEL_DISABLED");
|
||||
});
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isNull();
|
||||
assertThat(where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "NOTIFIED"))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scan crosses page boundaries without missing alerts or retaining all rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAlertPages() throws Exception
|
||||
{
|
||||
QRecord first = alert(0, AlertSeverity.ERROR);
|
||||
for(int i = 1; i <= 100; i++)
|
||||
{
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("page-" + i));
|
||||
}
|
||||
tick();
|
||||
assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(101).allSatisfy(row ->
|
||||
assertThat(row.getValueInstant("timedNotificationCheckedAt")).isEqualTo(clock.instant()));
|
||||
assertThat(fresh(first).getValueInteger("escalationLevel")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deliberate storage failure for transaction verification.
|
||||
*******************************************************************************/
|
||||
public static class RejectDelivery implements TableCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reject before a delivery can be stored.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
throw (new QException("Deliberate test failure"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class EntitiesTest
|
||||
{
|
||||
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
|
||||
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsEscalationPolicy.class, AlertsEscalationStep.class, AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
|
||||
AlertsMonitor.class, AlertsNotification.class, AlertsNotificationRecipient.class, AlertsNotificationPreference.class, AlertsMute.class, AlertsDelivery.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
|
||||
AlertsTeamMember.class, AlertsType.class, AlertsUserPreference.class);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
DROP TABLE IF EXISTS alerts_escalation_step;
|
||||
DROP TABLE IF EXISTS alerts_escalation_policy;
|
||||
-- H2 version of src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql, for unit tests (generated together).
|
||||
|
||||
DROP TABLE IF EXISTS alerts_heartbeat;
|
||||
@@ -374,3 +376,57 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:21
|
||||
-- comment: Admin escalation policies and their ordered destinations.
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_after_minutes INTEGER,
|
||||
max_repeats INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_policy PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_policy UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_step (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
policy_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
after_minutes INTEGER NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
user_id VARCHAR(100),
|
||||
team_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
only_if_status VARCHAR(16) DEFAULT 'OPEN' NOT NULL,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_step PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_step UNIQUE (policy_id, step_order),
|
||||
CONSTRAINT ck_alerts_escalation_step_order CHECK (step_order > 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_minutes CHECK (after_minutes >= 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_target CHECK (
|
||||
(target_type = 'USER' AND user_id IS NOT NULL AND team_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'TEAM' AND team_id IS NOT NULL AND user_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'CHANNEL' AND channel_id IS NOT NULL AND user_id IS NULL AND team_id IS NULL))
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:22
|
||||
-- comment: Type policies override the group's policy.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_type ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:23
|
||||
-- comment: A group policy is used when the type has none.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_group' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_group ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:24
|
||||
-- comment: Persist the timed job checkpoint, including attempts without recipients.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_alert' AND LOWER(column_name) = 'timed_notification_checked_at'
|
||||
ALTER TABLE alerts_alert ADD COLUMN timed_notification_checked_at TIMESTAMP;
|
||||
Reference in new issue
Block a user