All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.openremote.manager.alarm.AlarmService Maven / Gradle / Ivy

/*
 * Copyright 2024, OpenRemote Inc.
 *
 * See the CONTRIBUTORS.txt file in the distribution for a
 * full listing of individual contributors.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see .
 */
package org.openremote.manager.alarm;

import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.TypedQuery;
import org.apache.camel.builder.RouteBuilder;
import org.hibernate.Session;
import org.openremote.manager.notification.NotificationService;
import org.openremote.manager.security.ManagerIdentityProvider;
import org.openremote.model.Constants;
import org.openremote.model.PersistenceEvent;
import org.openremote.model.alarm.*;
import org.openremote.model.Container;
import org.openremote.model.ContainerService;
import org.openremote.container.message.MessageBrokerService;
import org.openremote.container.persistence.PersistenceService;
import org.openremote.container.timer.TimerService;
import org.openremote.manager.security.ManagerIdentityService;
import org.openremote.manager.web.ManagerWebService;
import org.openremote.manager.event.ClientEventService;

import org.openremote.model.event.shared.EventSubscription;
import org.openremote.model.event.shared.RealmFilter;
import org.openremote.model.notification.*;
import org.openremote.model.query.UserQuery;
import org.openremote.model.query.filter.RealmPredicate;
import org.openremote.model.query.filter.StringPredicate;
import org.openremote.model.security.User;
import org.openremote.model.util.TextUtil;

import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.util.*;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.openremote.container.util.MapAccess.getString;
import static org.openremote.model.Constants.OR_HOSTNAME;
import static org.openremote.model.alarm.Alarm.Source.*;

/**
 * A service for managing {@link SentAlarm}s. It also provides functionality for managing links between {@link SentAlarm}s
 * and {@link org.openremote.model.asset.Asset}s using {@link AlarmAssetLink}s.
 */
public class AlarmService extends RouteBuilder implements ContainerService {

    public static final Logger LOG = Logger.getLogger(AlarmService.class.getName());

    private Container container;
    private ClientEventService clientEventService;
    private ManagerIdentityService identityService;
    private NotificationService notificationService;
    private PersistenceService persistenceService;
    private TimerService timerService;

    @Override
    public int getPriority() {
        return ContainerService.DEFAULT_PRIORITY;
    }

    @Override
    public void init(Container container) throws Exception {
        this.container = container;
        this.clientEventService = container.getService(ClientEventService.class);
        this.identityService = container.getService(ManagerIdentityService.class);
        this.notificationService = container.getService(NotificationService.class);
        this.persistenceService = container.getService(PersistenceService.class);
        this.timerService = container.getService(TimerService.class);
        container.getService(MessageBrokerService.class).getContext().addRoutes(this);
        container.getService(ManagerWebService.class).addApiSingleton(
                new AlarmResourceImpl(timerService, identityService, this)
        );
        clientEventService.addSubscriptionAuthorizer((realm, authContext, eventSubscription) -> {
            if (!eventSubscription.isEventType(AlarmEvent.class) || authContext == null) {
                return false;
            }

            // If not a superuser force a filter for the users realm
            if (!authContext.isSuperUser()) {
                @SuppressWarnings("unchecked")
                EventSubscription subscription = (EventSubscription) eventSubscription;
                subscription.setFilter(new RealmFilter<>(authContext.getAuthenticatedRealmName()));
            }

            return true;
        });
    }

    @Override
    public void start(Container container) throws Exception {

    }

    @Override
    public void stop(Container container) throws Exception {

    }

    @Override
    public void configure() throws Exception {

    }

    /**
     * Returns a set of all realms of the given alarms.
     */
    protected Set getAlarmRealms(List alarms) {
        return alarms == null ? Set.of() : alarms.stream().map(SentAlarm::getRealm).collect(Collectors.toSet());
    }

    /**
     * Throws an {@link IllegalArgumentException} if {@code alarmId} is null or negative.
     */
    protected void validateAlarmId(Long alarmId) {
        if (alarmId == null) {
            throw new IllegalArgumentException("Missing alarm ID");
        }
        if (alarmId < 0) {
            throw new IllegalArgumentException("Alarm ID cannot be negative");
        }
    }

    /**
     * Throws an {@link IllegalArgumentException} if the given {@code alarmIds} are null, empty or negative.
     */
    protected void validateAlarmIds(Collection alarmIds) {
        if (alarmIds == null || alarmIds.isEmpty()) {
            throw new IllegalArgumentException("Missing alarm IDs");
        }
        alarmIds.forEach(this::validateAlarmId);
    }

    /**
     * Validates if the {@code alarm} is non-null and the user can access to the alarm realm.
     */
    protected void validateAlarmExistsAndAccessible(SentAlarm alarm, String userId) {
        if (alarm == null) {
            throw new EntityNotFoundException("Alarm does not exist");
        }
        validateRealmAccessibleToUser(userId, alarm.getRealm());
    }

    /**
     * Validates if the {@code alarms} are non-null, match the alarmIds length and the user has access to all alarm realms.
     */
    protected void validateAlarmsExistAndAccessible(List alarmIds, List alarms, String userId) {
        if (alarms == null || alarmIds.size() != alarms.size()) {
            throw new EntityNotFoundException("One or more alarms do not exist");
        }
        validateRealmsAccessibleToUser(userId, getAlarmRealms(alarms));
    }

    /**
     * Throws an {@link IllegalArgumentException} if the given {@code assetIds} are null or empty.
     */
    protected void validateAssetIds(Collection assetIds) {
        if (assetIds == null || assetIds.isEmpty()) {
            throw new IllegalArgumentException("Missing asset IDs");
        }
        assetIds.forEach(assetId -> {
            if (TextUtil.isNullOrEmpty(assetId)) {
                throw new IllegalArgumentException("Missing asset ID");
            }
        });
    }

    /**
     * Validates if the user has access to the given realm.
     */
    protected void validateRealmAccessibleToUser(String userId, String realm) {
        validateRealmsAccessibleToUser(userId, Set.of(realm));
    }

    /**
     * Validates if the user has access to all given realms.
     */
    protected void validateRealmsAccessibleToUser(String userId, Set realms) {
        if (userId == null) {
            return;
        }
        ManagerIdentityProvider identityProvider = identityService.getIdentityProvider();
        User user = identityProvider.getUser(userId);
        if (user == null) {
            throw new IllegalStateException("User does not exist");
        }

        if (!identityProvider.isMasterRealmAdmin(userId) &&
                (realms.size() > 1 || !identityProvider.isUserInRealm(userId, realms.stream().findFirst().orElse(null)))) {
            throw new SecurityException("Realm is not active or inaccessible");
        }
    }

    /**
     * Sends an alarm if the given user has access to the alarm realm.
     */
    public SentAlarm sendAlarm(Alarm alarm, List assetIds, String userId) {
        Objects.requireNonNull(alarm, "Alarm cannot be null");
        Objects.requireNonNull(alarm.getRealm(), "Alarm realm cannot be null");
        Objects.requireNonNull(alarm.getTitle(), "Alarm title cannot be null");
        Objects.requireNonNull(alarm.getSeverity(), "Alarm severity cannot be null");
        Objects.requireNonNull(alarm.getSource(), "Source cannot be null");
        Objects.requireNonNull(alarm.getSourceId(), "Source ID cannot be null");

        validateRealmAccessibleToUser(userId, alarm.getRealm());

        Date timestamp = new Date(timerService.getCurrentTimeMillis());
        SentAlarm sentAlarm = persistenceService.doReturningTransaction(entityManager -> entityManager.merge(new SentAlarm()
                .setAssigneeId(alarm.getAssigneeId())
                .setRealm(alarm.getRealm())
                .setTitle(alarm.getTitle())
                .setContent(alarm.getContent())
                .setSeverity(alarm.getSeverity())
                .setStatus(alarm.getStatus())
                .setSource(alarm.getSource())
                .setSourceId(alarm.getSourceId())
                .setCreatedOn(timestamp)
                .setLastModified(timestamp)));

        if (assetIds != null && !assetIds.isEmpty()) {
            linkAssets(assetIds, sentAlarm.getRealm(), sentAlarm.getId());
        }

        clientEventService.publishEvent(new AlarmEvent(alarm.getRealm(), PersistenceEvent.Cause.CREATE));
        if (alarm.getSeverity() == Alarm.Severity.HIGH) {
            Set excludeUserIds = alarm.getSource() == MANUAL ? Set.of(alarm.getSourceId()) : Set.of();
            sendAssigneeNotification(sentAlarm, excludeUserIds);
        }

        return sentAlarm;
    }

    /**
     * Sends e-mail and push notifications for an alarm to the alarm assignee. If an assignee is not set all users having
     * the {@link Constants#READ_ADMIN_ROLE} or {@link Constants#WRITE_ALARMS_ROLE} are notified.
     *
     * @param alarm          the alarm to send e-mail and push notifications for
     * @param excludeUserIds users matching these user IDs will are excluded from the notifications
     */
    protected void sendAssigneeNotification(SentAlarm alarm, Set excludeUserIds) {
        List users = getAlarmNotificationUsers(alarm);
        users.removeIf(user -> excludeUserIds.contains(user.getId()));
        if (users.isEmpty()) {
            LOG.fine("No matching users to send alarm notification");
            return;
        }

        LOG.fine("Sending alarm notification to " + users.size() + " matching user(s)");

        String title = String.format("Alarm: %s - %s", alarm.getSeverity(), alarm.getTitle());
        String url = getAlarmNotificationUrl(alarm);
        Map content = getAlarmNotificationContent(alarm, url);

        Notification email = new Notification()
                .setName("New Alarm")
                .setMessage(new EmailNotificationMessage()
                        .setHtml(getAlarmNotificationHtml(content))
                        .setSubject(title)
                        .setTo(users.stream()
                                .filter(user -> user.getEmail() != null && !user.getEmail().isBlank())
                                .map(user -> new EmailNotificationMessage.Recipient(user.getFullName(), user.getEmail())).toList()));

        Notification push = new Notification();
        push.setName("New Alarm")
                .setMessage(
                        new PushNotificationMessage()
                                .setTitle(title)
                                .setBody(getAlarmNotificationText(content))
                                .setAction(url == null ? null : new PushNotificationAction(url))
                )
                .setTargets(users.stream().map(user -> new Notification.Target(Notification.TargetType.USER, user.getId())).toList());

        notificationService.sendNotificationAsync(push, Notification.Source.INTERNAL, "alarms");
        notificationService.sendNotificationAsync(email, Notification.Source.INTERNAL, "alarms");
    }

    private List getAlarmNotificationUsers(SentAlarm alarm) {
        ManagerIdentityProvider identityProvider = identityService.getIdentityProvider();
        List users = new ArrayList<>();
        if (alarm.getAssigneeId() == null) {
            UserQuery userQuery = new UserQuery()
                    .realm(new RealmPredicate(alarm.getRealm()))
                    .clientRoles(new StringPredicate(Constants.WRITE_ALARMS_ROLE))
                    .realmRoles(new StringPredicate(Constants.REALM_ADMIN_ROLE))
                    .serviceUsers(false);
            users.addAll(Arrays.asList(identityProvider.queryUsers(userQuery)));
        } else {
            users.add(identityProvider.getUser(alarm.getAssigneeId()));
        }
        return users;
    }

    private String getAlarmNotificationUrl(SentAlarm alarm) {
        String defaultHostname = getString(container.getConfig(), OR_HOSTNAME, null);
        return defaultHostname == null ? null : String.format("https://%s/manager/#/alarms/%s?realm=%s", defaultHostname, alarm.getId(), alarm.getRealm());
    }

    private Map getAlarmNotificationContent(SentAlarm alarm, String url) {
        Map result = new LinkedHashMap<>();
        result.put("Title", alarm.getTitle());
        result.put("Content", alarm.getContent());
        result.put("Created", DateFormat.getDateTimeInstance().format(alarm.getCreatedOn()));
        result.put("Source", alarm.getSource().name());
        result.put("Severity", alarm.getSeverity().name());
        result.put("Status", alarm.getStatus().name());

        List assetLinks = getAssetLinks(alarm.getId(), null, alarm.getRealm()).stream().map(AlarmAssetLink::getAssetName).toList();
        result.put("Linked assets", assetLinks.isEmpty() ? "None" : String.join(", ", assetLinks));

        result.put("Assignee", TextUtil.isNullOrEmpty(alarm.getAssigneeUsername()) ? "None" : alarm.getAssigneeUsername());

        if (url != null) {
            result.put("URL", url);
        }

        return result;
    }

    private String getAlarmNotificationHtml(Map content) {
        StringBuilder sb = new StringBuilder("");
        content.forEach((key, value) -> sb.append(String.format("", key, value.replaceAll("\n", "
")))); sb.append("
%s%s
"); return sb.toString(); } private String getAlarmNotificationText(Map content) { StringBuilder sb = new StringBuilder(); content.forEach((key, value) -> sb.append(String.format("%s: %s\n", key, value))); return sb.toString(); } /** * Updates an existing alarm if the given user has access to the alarm realm. */ public void updateAlarm(Long alarmId, String userId, SentAlarm alarm) { SentAlarm oldAlarm = getAlarm(alarmId, userId); String oldAssigneeId = oldAlarm.getAssigneeId(); String newAssigneeId = alarm.getAssigneeId(); if (newAssigneeId != null) { validateRealmAccessibleToUser(newAssigneeId, alarm.getRealm()); } persistenceService.doTransaction(entityManager -> entityManager.createQuery(""" update SentAlarm set title=:title, content=:content, severity=:severity, status=:status, lastModified=:lastModified, assigneeId=:assigneeId where id =:id """) .setParameter("id", alarmId) .setParameter("title", alarm.getTitle()) .setParameter("content", alarm.getContent()) .setParameter("severity", alarm.getSeverity()) .setParameter("status", alarm.getStatus()) .setParameter("lastModified", new Timestamp(timerService.getCurrentTimeMillis())) .setParameter("assigneeId", newAssigneeId) .executeUpdate()); clientEventService.publishEvent(new AlarmEvent(alarm.getRealm(), PersistenceEvent.Cause.UPDATE)); if (alarm.getSeverity() == Alarm.Severity.HIGH) { Set excludeUserIds = Stream.of(userId, oldAssigneeId).filter(Objects::nonNull).collect(Collectors.toSet()); sendAssigneeNotification(getAlarm(alarmId, userId), excludeUserIds); } } /** * Links one asset to an existing alarm. */ public void linkAsset(String assetId, String realm, Long alarmId) { linkAssets(List.of(assetId), realm, alarmId); } /** * Links multiple assets to an existing alarm. */ public void linkAssets(List assetIds, String realm, Long alarmId) { linkAssets(assetIds.stream().map(assetId -> new AlarmAssetLink(realm, alarmId, assetId)).toList(), null); } /** * Links multiple assets to existing alarms if the given user has access to the alarm realms. */ public void linkAssets(List links, String userId) { if (links == null || links.isEmpty()) { throw new IllegalArgumentException("Missing links"); } Set alarmIds = links.stream().map(link -> link.getId().getAlarmId()).collect(Collectors.toSet()); validateAlarmIds(alarmIds); Set assetIds = links.stream().map(link -> link.getId().getAssetId()).collect(Collectors.toSet()); validateAssetIds(assetIds); if (userId != null) { Set realms = links.stream().map(link -> link.getId().getRealm()).collect(Collectors.toSet()); validateRealmsAccessibleToUser(userId, realms); } persistenceService.doTransaction(entityManager -> entityManager.unwrap(Session.class).doWork(connection -> { PreparedStatement st = connection.prepareStatement(""" insert into ALARM_ASSET_LINK (sentalarm_id, realm, asset_id, created_on) values (?, ?, ?, ?) on conflict (sentalarm_id, realm, asset_id) do nothing """); for (AlarmAssetLink link : links) { st.setLong(1, link.getId().getAlarmId()); st.setString(2, link.getId().getRealm()); st.setString(3, link.getId().getAssetId()); st.setTimestamp(4, new Timestamp(timerService.getCurrentTimeMillis())); st.addBatch(); } st.executeBatch(); })); } /** * Returns the assets linked to an alarm if the given user has access to the alarm realm. */ public List getAssetLinks(Long alarmId, String userId, String realm) throws IllegalArgumentException { getAlarm(alarmId, userId); return persistenceService.doReturningTransaction(entityManager -> entityManager.createQuery(""" select aal from AlarmAssetLink aal where aal.id.realm = :realm and aal.id.sentalarmId = :alarmId order by aal.createdOn desc """, AlarmAssetLink.class) .setParameter("realm", realm) .setParameter("alarmId", alarmId) .getResultList() ); } /** * Retrieves the details of an existing alarm if the given user has access to the alarm realm. */ public SentAlarm getAlarm(Long alarmId, String userId) throws IllegalArgumentException { validateAlarmId(alarmId); SentAlarm alarm; try { alarm = persistenceService.doReturningTransaction(entityManager -> entityManager.createQuery("select sa from SentAlarm sa where sa.id = :id", SentAlarm.class) .setParameter("id", alarmId) .getSingleResult()); } catch (PersistenceException e) { alarm = null; } validateAlarmExistsAndAccessible(alarm, userId); return alarm; } /** * Retrieves the details of existing alarms if the given user has access to the alarm realms. */ public List getAlarms(List alarmIds, String userId) throws IllegalArgumentException { validateAlarmIds(alarmIds); List alarms; try { alarms = persistenceService.doReturningTransaction(entityManager -> entityManager.createQuery("select sa from SentAlarm sa where sa.id in :ids", SentAlarm.class) .setParameter("ids", alarmIds) .getResultList() ); } catch (PersistenceException e) { alarms = null; } validateAlarmsExistAndAccessible(alarmIds, alarms, userId); return alarms; } /** * Retrieves all existing alarms in a realm. The {@code status}, {@code assetId} and {@code assigneeId} parameters * are optional and if non-null are used for filtering the alarms. */ public List getAlarms(String realm, Alarm.Status status, String assetId, String assigneeId) throws IllegalArgumentException { Map parameters = new HashMap<>(); StringBuilder sb = new StringBuilder("select sa from SentAlarm sa "); if (assetId != null) { sb.append("join AlarmAssetLink aal on sa.id = aal.id.sentalarmId where sa.realm = :realm and aal.id.assetId = :assetId "); parameters.put("assetId", assetId); } else { sb.append("where sa.realm = :realm "); } parameters.put("realm", realm); if (status != null) { sb.append("and sa.status = :status "); parameters.put("status", status); } if (assigneeId != null) { sb.append("and sa.assigneeId = :assigneeId "); parameters.put("assigneeId", assigneeId); } sb.append("order by sa.createdOn desc"); return persistenceService.doReturningTransaction(entityManager -> { TypedQuery query = entityManager.createQuery(sb.toString(), SentAlarm.class); parameters.forEach(query::setParameter); return query.getResultList(); }); } /** * Removes an existing alarm if the given user has access to the alarm realm. */ public void removeAlarm(Long alarmId, String userId) { SentAlarm alarm = getAlarm(alarmId, userId); persistenceService.doTransaction(entityManager -> entityManager .createQuery("delete SentAlarm where id = :id") .setParameter("id", alarmId) .executeUpdate() ); clientEventService.publishEvent(new AlarmEvent(alarm.getRealm(), PersistenceEvent.Cause.DELETE)); } /** * Removes existing alarms if the given user has access to the alarm realms. */ public void removeAlarms(List alarmIds, String userId) throws IllegalArgumentException { List alarms = getAlarms(alarmIds, userId); persistenceService.doTransaction(entityManager -> entityManager.createQuery("delete from SentAlarm sa where sa.id in :ids") .setParameter("ids", alarmIds) .executeUpdate() ); getAlarmRealms(alarms).forEach(realm -> clientEventService.publishEvent(new AlarmEvent(realm, PersistenceEvent.Cause.DELETE))); } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy