فهرست منبع

Added templates for emails

Lukas Cerny 2 سال پیش
والد
کامیت
01432da746

+ 1 - 1
Dockerfile

@@ -3,7 +3,7 @@ ARG config_file
 COPY src /app/src
 COPY $config_file /app/config.yaml
 COPY gradle /app/gradle
-COPY build.gradle settings.gradle gradlew /app/
+COPY build.gradle settings.gradle gradlew gradle.properties /app/
 WORKDIR /app/
 RUN ./gradlew assemble
 

+ 6 - 5
build.gradle

@@ -2,8 +2,8 @@ plugins {
     id 'java'
 }
 
-group 'cz.senslog'
-version '1.1'
+group projectGroup
+version projectVersion
 
 repositories {
     mavenLocal()
@@ -36,14 +36,15 @@ dependencies {
     implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.18.0'
 
     implementation group: 'com.beust', name: 'jcommander', version: '1.78'
-    implementation group: 'org.yaml', name: 'snakeyaml', version: '1.26'
+    implementation group: 'org.yaml', name: 'snakeyaml', version: '1.33'
     implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.1.3'
-    implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
+    implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9'
+    implementation group: 'com.github.spullara.mustache.java', name: 'compiler', version: '0.9.10'
 
     implementation group: 'org.jdbi', name: 'jdbi3-postgres', version: '3.12.2'
     implementation group: 'org.jdbi', name: 'jdbi3-jodatime2', version: '3.12.2'
     implementation group: 'com.zaxxer', name: 'HikariCP', version: '3.4.2'
-    implementation group: 'org.postgresql', name: 'postgresql', version: '42.2.26'
+    implementation group: 'org.postgresql', name: 'postgresql', version: '42.2.27'
 
     implementation group: 'javax.mail', name: 'javax.mail-api', version: '1.6.2'
     implementation group: 'com.sun.mail', name: 'javax.mail', version: '1.6.2'

+ 285 - 0
config/config.yaml

@@ -0,0 +1,285 @@
+general:
+  firstStartAt: "00:20:00" # hh:mm:ss
+
+emailServers:
+  lspEmail:
+    smtpHost: "10.0.0.100" # "mail.lesprojekt.cz"
+    smtpPort: 465
+    authUsername: "watchdog@senslog.org"
+    authPassword: "5jspdD"
+
+senslogServers:
+  lspSenslog15:
+    baseUrl: "http://sensor.lesprojekt.cz/senslog15"
+    auth:
+      username: "watchdog"
+      password: "HAFhaf"
+
+messageBrokers:
+  zcuOnlyEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+#        - "luccerny@ntis.zcu.cz"
+        - "fialar@kgm.zcu.cz"
+        - "kepka@ccss.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+  mikeEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+        - "luccerny@ntis.zcu.cz"
+        - "kepka@ccss.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+  krivanekEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+#        - "luccerny@ntis.zcu.cz"
+        - "krivanek@lesprojekt.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+  lspServisEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+        - "luccerny@ntis.zcu.cz"
+        - "servis@lesprojekt.cz"
+        - "kepka@ccss.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+  lspInnovarEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+        - "servis@lesprojekt.cz"
+        - "kepka@ccss.cz"
+        - "kubicek@lesprojekt.cz"
+        - "charvat_junior@lesprojekt.cz"
+        - "maria.ruiperezgonzalez@wur.nl"
+        - "luccerny@ntis.zcu.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+  lspOnlyServisEmail:
+    type: EMAIL
+    config:
+      server: lspEmail
+      messageTemplate: "default"
+      senderEmail: "watchdog@senslog.org"
+      recipientEmail:
+        - "luccerny@ntis.zcu.cz"
+        - "servis@lesprojekt.cz"
+      subject: "[Watchdog] Report SensLog (CZ) - $group.name"
+
+dataProviders:
+  wsSensLogKynsperk:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "kynsperk"
+
+  wsSensLogRostenice:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "rostenice_pudni"
+
+  wsSensLogZcu:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "zcu"
+
+  wsSensLogOsek:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "osek"
+
+  wsSensLogSmiltene:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "Smiltene"
+
+  wsSensLogMengele:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "Mengele"
+
+  wsSensLogZabcice:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "neudert"
+
+  wsSensLogInnovar:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "Innovar"
+
+  wsSensLogZelenec:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "zelenec"
+
+  wsSensLogCepirohy:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "agriclima"
+
+  wsSensLogSanJuan:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "sanjuan"
+
+  wsSensLogSVSMP:
+    type: WEB_SERVICE
+    config:
+      server: lspSenslog15
+      groupName: "svsmp"
+
+groups:
+  kynsperk:
+    name: "Kynšperk"
+    active: true
+    dataProvider: wsSensLogKynsperk
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  rostenice:
+    name: "Rostěnice"
+    active: true
+    dataProvider: wsSensLogRostenice
+    messageBroker: mikeEmail
+    resultType: FAIL
+    period: 86400
+
+  zcu:
+    name: "ZČU Robčice"
+    active: true
+    dataProvider: wsSensLogZcu
+    messageBroker: zcuOnlyEmail
+    resultType: FAIL
+    period: 86400
+
+  osek:
+    name: "Osek"
+    active: true
+    dataProvider: wsSensLogOsek
+    messageBroker: mikeEmail
+    resultType: FAIL
+    period: 86400
+
+  latviaSmiltene:
+    name: "Latvia Smiltene"
+    active: true
+    dataProvider: wsSensLogSmiltene
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  latviaMengele:
+    name: "Latvia Meņgele"
+    active: true
+    dataProvider: wsSensLogMengele
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  zabcice:
+    name: "Žabčice"
+    active: true
+    dataProvider: wsSensLogZabcice
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  innovar:
+    name: "InnoVar"
+    active: true
+    dataProvider: wsSensLogInnovar
+    messageBroker: lspInnovarEmail
+    resultType: FAIL
+    period: 86400
+
+  zelenec:
+    name: "Zeleneč"
+    active: true
+    dataProvider: wsSensLogZelenec
+    messageBroker: krivanekEmail
+    resultType: FAIL
+    period: 86400
+
+  cepirohy:
+    name: "Čepirohy"
+    active: true
+    dataProvider: wsSensLogCepirohy
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  sanJuan:
+    name: "San Juan (Argentina)"
+    active: true
+    dataProvider: wsSensLogSanJuan
+    messageBroker: lspServisEmail
+    resultType: FAIL
+    period: 86400
+
+  svsmpSadky:
+    name: "SVSMP Sádky"
+    active: true
+    dataProvider: wsSensLogSVSMP
+    messageBroker: lspOnlyServisEmail
+    resultType: FAIL
+    period: 86400
+
+superGroups:
+#  osek&zcu:
+#    name: "Osek & ZČU Robčice"
+#    messageBroker: mikeEmail
+#    resultType: FAIL
+#    period: 86400
+#    groups: [ osek, zcu ]
+
+#  lspService:
+#    name: "Latvia, Žabčice, InnoVar"
+#    messageBroker: lspServisEmail
+#    resultType: FAIL
+#    period: 86400
+#    groups: [ latviaSmiltene, latviaMengele, zabcice, innovar ]
+
+monitoredObjects:
+
+  1305167562258386:
+    period: 86400
+    groups: [ zcu ]
+  
+  1305167549174391:
+    period: 86400
+    groups: [ osek ]
+    sensors: [ 470020001, 340070001, 490010001, 410050001, 470010001, 560030000, 360200000, 460010001, 480020001 ]

+ 36 - 48
config/test-foodie.yaml

@@ -16,28 +16,11 @@ senslogServers:
       password: "HAFhaf"
 
 messageBrokers:
-  mikeEmail:
-    type: EMAIL
-    config:
-      server: lspEmail
-      senderEmail: "watchdog@senslog.org"
-      recipientEmail:
-        - "luccerny@ntis.zcu.cz"
-      subject: "[Watchdog] Test Report SensLog (CZ) - $group.name"
-
-  lspServisEmail:
-    type: EMAIL
-    config:
-      server: lspEmail
-      senderEmail: "watchdog@senslog.org"
-      recipientEmail:
-        - "luccerny@ntis.zcu.cz"
-      subject: "[Watchdog] Test Report SensLog (CZ) - $group.name"
-
-  zcuOnlyEmail:
+  testEmail:
     type: EMAIL
     config:
       server: lspEmail
+      messageTemplate: "default"
       senderEmail: "watchdog@senslog.org"
       recipientEmail:
         - "luccerny@ntis.zcu.cz"
@@ -122,7 +105,7 @@ groups:
 #    name: "Kynšperk"
 #    active: true
 #    dataProvider: wsSensLogKynsperk
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 
@@ -130,7 +113,7 @@ groups:
 #    name: "Rostěnice"
 #    active: true
 #    dataProvider: wsSensLogRostenice
-#    messageBroker: mikeEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
@@ -138,7 +121,7 @@ groups:
 #    name: "ZČU Robčice"
 #    active: true
 #    dataProvider: wsSensLogZcu
-#    messageBroker: zcuOnlyEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
@@ -146,7 +129,7 @@ groups:
 #    name: "Osek"
 #    active: true
 #    dataProvider: wsSensLogOsek
-#    messageBroker: mikeEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
@@ -154,23 +137,23 @@ groups:
 #    name: "Latvia Smiltene"
 #    active: true
 #    dataProvider: wsSensLogSmiltene
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
-  latviaMengele:
-    name: "Latvia Meņģele"
-    active: true
-    dataProvider: wsSensLogMengele
-    messageBroker: lspServisEmail
-    resultType: FAIL
-    period: 86400
+#  latviaMengele:
+#    name: "Latvia Meņģele"
+#    active: true
+#    dataProvider: wsSensLogMengele
+#    messageBroker: testEmail
+#    resultType: FAIL
+#    period: 86400
 
 #  zabcice:
 #    name: "Žabčice"
 #    active: true
 #    dataProvider: wsSensLogZabcice
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 
@@ -178,7 +161,7 @@ groups:
 #    name: "Zeleneč"
 #    active: true
 #    dataProvider: wsSensLogZelenec
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
@@ -186,43 +169,48 @@ groups:
 #    name: "Čepirohy"
 #    active: true
 #    dataProvider: wsSensLogCepirohy
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #
-  sanJuan:
-    name: "San Juan (Argentina)"
-    active: true
-    dataProvider: wsSensLogSanJuan
-    messageBroker: lspServisEmail
-    resultType: FAIL
-    period: 86400
-
-#  innovar:
-#    name: "InnoVar"
+#  sanJuan:
+#    name: "San Juan (Argentina)"
 #    active: true
-#    dataProvider: wsSensLogInnovar
-#    messageBroker: lspServisEmail
+#    dataProvider: wsSensLogSanJuan
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 
+  innovar:
+    name: "InnoVar"
+    active: true
+    dataProvider: wsSensLogInnovar
+    messageBroker: testEmail
+    resultType: FAIL
+    period: 86400
+
 #  svsmpSadky:
 #    name: "SVSMP Sádky"
 #    active: true
 #    dataProvider: wsSensLogSVSMP
-#    messageBroker: lspServisEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 
 superGroups:
 #  osek&zcu:
 #    name: "ZČU Robčice"
-#    messageBroker: zcuOnlyEmail
+#    messageBroker: testEmail
 #    resultType: FAIL
 #    period: 86400
 #    groups: [ zcu, osek ]
 
 monitoredObjects:
+#  1305167549174391:
+#    period: 86400
+#    groups: [ osek ]
+#    sensors: [ 470020001, 340070001, 490010001, 410050001, 470010001, 560030000, 360200000, 460010001, 480020001 ]
+
 #  4152365:
 #    period: 86400
 #    groups: [ sanJuan ]

+ 3 - 0
gradle.properties

@@ -0,0 +1,3 @@
+projectGroup=cz.senslog
+projectName=watchdog
+projectVersion=1.1.0

+ 2 - 2
settings.gradle

@@ -1,2 +1,2 @@
-rootProject.name = 'watchdog'
-
+rootProject.name=projectName
+

+ 3 - 0
src/main/java/cz/senslog/watchdog/app/Application.java

@@ -26,6 +26,9 @@ public class Application extends Thread {
 
     private static final Logger logger = LogManager.getLogger(Application.class);
 
+    public static String COMPILED_VERSION = "1.1.0";
+    public static String BUILD_VERSION = "unknown";
+
     private final Parameters params;
 
     static Thread init(String... args) throws IOException {

+ 10 - 10
src/main/java/cz/senslog/watchdog/app/Main.java

@@ -1,10 +1,10 @@
-package cz.senslog.watchdog.app;
-
-import java.io.IOException;
-
-public class Main {
-
-    public static void main(String[] args) throws IOException {
-        Application.init(args).start();
-    }
-}
+package cz.senslog.watchdog.app;
+
+import java.io.IOException;
+
+public class Main {
+
+    public static void main(String[] args) throws IOException {
+        Application.init(args).start();
+    }
+}

+ 10 - 1
src/main/java/cz/senslog/watchdog/config/EmailMessageBrokerConfig.java

@@ -1,5 +1,7 @@
 package cz.senslog.watchdog.config;
 
+import cz.senslog.watchdog.messagebroker.template.HtmlTemplateType;
+
 import java.util.HashSet;
 import java.util.Set;
 
@@ -9,19 +11,22 @@ public class EmailMessageBrokerConfig extends MessageBrokerConfig {
     private final String senderEmail;
     private final Set<String> recipientEmails;
     private final String subject;
+    private final HtmlTemplateType templateType;
 
     public static EmailMessageBrokerConfig create(String id, PropertyConfig config) {
         return new EmailMessageBrokerConfig(id,
                 config.getStringProperty("server"),
+                config.getStringProperty("messageTemplate"),
                 config.getStringProperty("senderEmail"),
                 new HashSet<>(config.getArrayPropertyOf("recipientEmail", String.class)),
                 config.getStringProperty("subject")
         );
     }
 
-    public EmailMessageBrokerConfig(String id, String serverId, String senderEmail, Set<String> recipientEmails, String subject) {
+    public EmailMessageBrokerConfig(String id, String serverId, String messageTemplate, String senderEmail, Set<String> recipientEmails, String subject) {
         super(id, MessageBrokerType.EMAIL);
         this.serverId = serverId;
+        this.templateType = HtmlTemplateType.of(messageTemplate);
         this.senderEmail = senderEmail;
         this.recipientEmails = recipientEmails;
         this.subject = subject;
@@ -42,4 +47,8 @@ public class EmailMessageBrokerConfig extends MessageBrokerConfig {
     public String getSubject() {
         return subject;
     }
+
+    public HtmlTemplateType getTemplateType() {
+        return templateType;
+    }
 }

+ 13 - 2
src/main/java/cz/senslog/watchdog/core/Watcher.java

@@ -9,10 +9,12 @@ import cz.senslog.watchdog.messagebroker.MessageBroker;
 import cz.senslog.watchdog.provider.DataProvider;
 import cz.senslog.watchdog.provider.ProvidedData;
 import cz.senslog.watchdog.provider.ProvidedObject;
+import cz.senslog.watchdog.util.DateTrunc;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import java.time.*;
+import java.time.temporal.ChronoUnit;
 import java.util.*;
 
 import static cz.senslog.watchdog.domain.StatusReport.*;
@@ -40,8 +42,17 @@ public final class Watcher {
 
     public void check() {
 
-        // LocalDateTime fakedNow = LocalDateTime.of(LocalDate.now(), LocalTime.of(0, 20));
-        Instant now = /* ZonedDateTime.of(fakedNow, ZoneId.systemDefault()).toInstant(); */ Instant.now();
+//        LocalTime actualTime = LocalTime.now(); // LocalTime.of(1, 20, 34, 4422);
+//        Instant now = ZonedDateTime.of(
+//                LocalDateTime.of(
+//                        LocalDate.now(), LocalTime.of(actualTime.getHour(), actualTime.getMinute())
+//                ), ZoneId.systemDefault())
+//                .toInstant();
+
+        // actual date is trunc to the main day so it wont work for period != 8640
+        OffsetDateTime truncDate = DateTrunc.trunc(OffsetDateTime.now(), DateTrunc.Option.DAY);
+        Instant now = truncDate.toInstant().plusSeconds(truncDate.getOffset().getTotalSeconds());
+
         ProvidedData data = dataProvider.getData();
 
         List<SimpleReport> reports = new ArrayList<>();

+ 20 - 0
src/main/java/cz/senslog/watchdog/messagebroker/MessageException.java

@@ -0,0 +1,20 @@
+package cz.senslog.watchdog.messagebroker;
+
+public class MessageException extends RuntimeException {
+
+    public MessageException(String message) {
+        super(message);
+    }
+
+    public MessageException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public MessageException(Throwable cause) {
+        super(cause);
+    }
+
+    public MessageException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+}

+ 35 - 212
src/main/java/cz/senslog/watchdog/messagebroker/broker/EmailMessageBroker.java

@@ -4,11 +4,11 @@ import cz.senslog.watchdog.config.EmailMessageBrokerConfig;
 import cz.senslog.watchdog.core.connection.EmailServerConnection;
 import cz.senslog.watchdog.domain.*;
 import cz.senslog.watchdog.messagebroker.MessageBrokerHandler;
+import cz.senslog.watchdog.messagebroker.MessageException;
 import cz.senslog.watchdog.messagebroker.MessageStatus;
 import cz.senslog.watchdog.messagebroker.MultiMessageBroker;
-import cz.senslog.watchdog.messagebroker.writer.HtmlTableWriter;
-import cz.senslog.watchdog.messagebroker.writer.TableWriter;
-import cz.senslog.watchdog.util.Tuple;
+import cz.senslog.watchdog.messagebroker.template.HtmlTemplate;
+import cz.senslog.watchdog.messagebroker.template.HtmlTemplateManager;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
@@ -17,14 +17,9 @@ import java.time.LocalDateTime;
 import java.util.*;
 
 import static cz.senslog.watchdog.config.MessageBrokerType.EMAIL;
-import static cz.senslog.watchdog.domain.StatusReport.*;
-import static java.time.format.DateTimeFormatter.ofPattern;
 
 public class EmailMessageBroker extends MultiMessageBroker {
 
-    private static final String BREAK_LINE = "<br />";
-    private static final String HORIZONTAL_SEPARATOR = "<hr>";
-
     private static final String SUBSTITUTABLE_VARIABLE_PREFIX = "$";
 
     private static final Logger logger = LogManager.getLogger(EmailMessageBroker.class);
@@ -36,222 +31,50 @@ public class EmailMessageBroker extends MultiMessageBroker {
         this.serverConnection = serverConnection;
         this.messageConfig = messageConfig;
     }
-
-    private static int[] createStatusProgressForUnit(int[] statuses) {
-        int[] statusToColor = new int[statuses.length];
-
-        int max = 0;
-        for (int status : statuses) {
-            max += status;
-        }
-
-        int maxPercent = 100;
-        for (int i = 0; i < statuses.length; i++) {
-            int percent = (int) ((double) statuses[i] / (double) max * 100.0);
-            statusToColor[i] = percent;
-            maxPercent -= percent;
-        }
-
-        if (maxPercent != 0) {
-            for (int i = statusToColor.length - 1; i >= 0; i--) {
-                if (statusToColor[i] != 0) {
-                    statusToColor[i] += maxPercent;
-                }
-            }
-        }
-
-
-        return statusToColor;
-    }
-
-    private Tuple<String, Map<String, String>> createMessage(Report report) {
-        StringBuilder content = new StringBuilder();
-        final String rowStyle = "border: 1px solid #dddddd; text-align: left; padding: 8px;";
-
-        boolean isMessages = !report.getMessages().isEmpty();
-        boolean isRecords = !report.getReports().isEmpty();
-
-        Map<String, String> operationProperties = report.getOperationProperties();
-        Map<String, String> substitutableVariables = new HashMap<>(operationProperties.size());
-        if (!operationProperties.isEmpty()) {
-            for (Map.Entry<String, String> property : operationProperties.entrySet()) {
-                if (property.getKey().startsWith(SUBSTITUTABLE_VARIABLE_PREFIX)) {
-                    substitutableVariables.put(property.getKey(), property.getValue());
-                    operationProperties.remove(property.getKey());
-                }
-            }
-
-            if (!operationProperties.isEmpty()) {
-                TableWriter tableSourceWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
-                        .cell("Operation Type").cell("Operation Value").end();
-
-                for (Map.Entry<String, String> operationEntry : operationProperties.entrySet()) {
-                    tableSourceWriter.row(rowStyle)
-                            .cell(operationEntry.getKey(), rowStyle)
-                            .cell(operationEntry.getValue(), rowStyle)
-                            .end();
-                }
-
-                content.append(tableSourceWriter.table()).append(BREAK_LINE);
-            }
-        }
-
-        if (isMessages || !isRecords) {
-            TableWriter tableMsgWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
-                    .cell("Messages").end();
-
-            for (String message : report.getMessages()) {
-                tableMsgWriter.row(rowStyle).cell(message, rowStyle).end();
-            }
-
-            if (!isRecords) {
-                tableMsgWriter.row(rowStyle).cell("All received observations are valid.");
-            }
-
-            content.append(tableMsgWriter.table()).append(BREAK_LINE);
-        }
-
-        if (isRecords) {
-
-            boolean renderReportTable = false;
-            Map<Unit, List<ObservationInfo>> unitsToReport = new HashMap<>();
-            Map<Unit, Tuple<Unit, int[]>> unitToStatus = new HashMap<>();
-            for (SimpleReport simpleReport : report.getReports()) {
-                StatusReport status = simpleReport.getStatus();
-                boolean isOk = status.equals(OK);
-                if (simpleReport.getRecord() instanceof ObservationInfo) {
-                    ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
-                    Unit unit = observation.getSource().getUnit();
-
-                    int[] statusReportCounters = unitToStatus.computeIfAbsent(unit,
-                                    u -> Tuple.of(u, new int[StatusReport.values().length]))
-                            .getItem2();
-
-                    statusReportCounters[status.ordinal()] += 1;
-
-                    List<ObservationInfo> observations = unitsToReport.computeIfAbsent(unit, u -> new ArrayList<>());
-
-                    if (!isOk) {
-                        observations.add(observation);
-                        renderReportTable = true;
-                    }
-                }
-            }
-
-            TableWriter tableUnitStatusWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
-                    .cell("unitName").cell("unitId").end();
-
-            List<Tuple<Unit, int[]>> statuses = new ArrayList<>(unitToStatus.values());
-            statuses.sort(Comparator.comparing(e -> e.getItem2()[OK.ordinal()] != 0));
-            for (Tuple<Unit, int[]> unitEntry : statuses) {
-                Unit unit = unitEntry.getItem1();
-                // TODO change color according to the statu
-                int[] statusProgress = createStatusProgressForUnit(unitEntry.getItem2());
-                int percentOK = statusProgress[OK.ordinal()];
-                int percentFAIL = statusProgress[FAIL.ordinal()] + statusProgress[OK.ordinal()];
-                int percentNODATA = statusProgress[NO_DATA.ordinal()] + percentFAIL;
-                String color = String.format("linear-gradient(135deg, #CCFFCC %d%%, #FFCCCC 0 %d%%, white 0 %d%%)",
-                        percentOK, percentFAIL,  percentNODATA);
-
-                tableUnitStatusWriter.row(rowStyle + "background: " + color)
-                        .cell(unit.getName(), rowStyle)
-                        .cell(String.valueOf(unit.getId()), rowStyle)
-                        .end();
-            }
-
-            content.append(tableUnitStatusWriter.table());
-
-            if (renderReportTable) {
-
-                content.append(HORIZONTAL_SEPARATOR).append(HORIZONTAL_SEPARATOR).append(BREAK_LINE);
-
-                TableWriter tableReportWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
-                        .cell("unitName (unitId)").cell("sensorName (sensorId)").cell("timestamp").end();
-
-
-                for (Map.Entry<Unit, List<ObservationInfo>> unitEntry : unitsToReport.entrySet()) {
-                    for (ObservationInfo observation : unitEntry.getValue()) {
-                        Source source = observation.getSource();
-                        Unit unit = source.getUnit();
-                        Sensor sensor = source.getSensor();
-
-                        String unitCell = String.format("%s (%s)", unit.getName(), unit.getId());
-                        String sensorCell = String.format("%s (%s)", sensor.getName(), sensor.getId());
-
-                        tableReportWriter.row(rowStyle + "background-color: #FFCCCC")
-                                .cell(unitCell, rowStyle)
-                                .cell(sensorCell, rowStyle)
-                                .cell(observation.getTimestamp().toString(), rowStyle)
-                                .end();
-                    }
-
-                    tableReportWriter.emptyRow().emptyRow().emptyRow();
-
-                }
-
-            /*
-            report.getReports().sort(Comparator.comparing(SimpleReport::getStatus).reversed());
-            for (SimpleReport simpleReport : report.getReports()) {
-                boolean isOk = simpleReport.getStatus().equals(OK);
-                // ALLOW ONLY NOT OK REPORTS -> // TODO create a template system
-                if (!isOk && simpleReport.getRecord() instanceof ObservationInfo) {
-                    ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
-                    Source source = observation.getSource();
-
-                    String unitCell = String.format("%s (%s)", source.getUnit().getName(), source.getUnit().getId());
-                    String sensorCell = String.format("%s (%s)", source.getSensor().getName(), source.getSensor().getId());
-
-                    tableReportWriter.row(rowStyle + "background-color: " + (isOk ? "#CCFFCC" : "#FFCCCC"))
-                            .cell(unitCell, rowStyle)
-                            .cell(sensorCell, rowStyle)
-                            .cell(observation.getTimestamp().toString(), rowStyle)
-                          //  .cell(reportedTime, rowStyle)
-                          //  .cell(simpleReport.getStatus().name(), rowStyle)
-                            .end();
-                }
-            }
-             */
-
-                content.append(tableReportWriter.table()).append(BREAK_LINE);
-            }
-        }
-
-        return Tuple.of(content.toString(), substitutableVariables);
-    }
-
-
     @Override
     public void send(Report[] reports, MessageBrokerHandler status) {
-        if (reports == null || reports.length <= 0) {
+        if (reports == null || reports.length == 0) {
             logger.info("Nothing to send. The receive report is null.");
             status.handle(new MessageStatus(null, "No report to send.", EMAIL.name())); return;
         }
 
         try {
-            StringBuilder reportMessage = new StringBuilder();
-            Map<String, String> substitutableVariables = new HashMap<>();
-            for (Report report : reports) {
-                Tuple<String, Map<String, String>> message = createMessage(report);
-                for (Map.Entry<String, String> varEntry : message.getItem2().entrySet()) {
-                    if (substitutableVariables.containsKey(varEntry.getKey())) {
-                        String oldValue = substitutableVariables.get(varEntry.getKey());
-                        substitutableVariables.put(varEntry.getKey(), oldValue + " & " + varEntry.getValue());
-                    } else {
-                        substitutableVariables.put(varEntry.getKey(), varEntry.getValue());
-                    }
-                }
-
-                reportMessage.append(message.getItem1())
-                        .append(HORIZONTAL_SEPARATOR).append(HORIZONTAL_SEPARATOR)
-                        .append(BREAK_LINE);
-            }
+            HtmlTemplate template = HtmlTemplateManager.getTemplate(messageConfig.getTemplateType());
+            Report report = mergeReports(reports);
+            String reportMessage = template.createMessage(report);
             logger.info("Sending a message via email.");
-            serverConnection.send(reportMessage.toString(), substitutableVariables, messageConfig);
+            serverConnection.send(reportMessage, report.getOperationProperties(), messageConfig);
             logger.info("The message was send successfully.");
             status.handle(MessageStatus.success(LocalDateTime.now(), EMAIL.name()));
-        } catch (MessagingException e) {
+        } catch (MessagingException | MessageException e) {
             logger.catching(e);
             status.handle(MessageStatus.error(LocalDateTime.now(), e.getMessage(), EMAIL.name()));
         }
     }
+
+    private static Report mergeReports(Report... reports) {
+        if (reports == null) {
+            return null;
+        }
+        if (reports.length == 1) {
+            return reports[0];
+        }
+        Report newReport = new Report(reports[0].getCreated(), new ArrayList<>(), new ArrayList<>(), new HashMap<>());
+        for (Report report : reports) {
+            newReport.getReports().addAll(report.getReports());
+            newReport.getMessages().addAll(report.getMessages());
+            Map<String, String> opVar = newReport.getOperationProperties();
+            for (Map.Entry<String, String> entry : report.getOperationProperties().entrySet()) {
+                if (entry.getKey().startsWith(SUBSTITUTABLE_VARIABLE_PREFIX)) {
+                    if (opVar.containsKey(entry.getKey())) {
+                        String oldValue = opVar.get(entry.getKey());
+                        opVar.put(entry.getKey(), oldValue + " & " + entry.getValue());
+                    } else {
+                        opVar.put(entry.getKey(), entry.getValue());
+                    }
+                }
+            }
+        }
+        return newReport;
+    }
 }

+ 81 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/DefaultHtmlTemplate.java

@@ -0,0 +1,81 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+import java.util.List;
+import cz.senslog.watchdog.domain.*;
+import cz.senslog.watchdog.messagebroker.MessageException;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static cz.senslog.watchdog.domain.StatusReport.*;
+
+public class DefaultHtmlTemplate extends HtmlTemplate {
+
+    public DefaultHtmlTemplate() {
+        super(HtmlTemplateType.DEFAULT);
+    }
+
+    @Override
+    public String createMessage(Report report) throws MessageException {
+        Map<Unit, UnitToReport> units = new HashMap<>();
+
+        for (SimpleReport simpleReport : report.getReports()) {
+            StatusReport status = simpleReport.getStatus();
+
+            if (simpleReport.getRecord() instanceof ObservationInfo) {
+                ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
+                Unit unit = observation.getSource().getUnit();
+
+                UnitToReport unitToReport = units.computeIfAbsent(unit,
+                        u -> new UnitToReport(u.getName(), u.getId(), status)
+                );
+                unitToReport.updateStatus(status);
+
+                if (!status.equals(OK)) {
+                    Sensor sensor = observation.getSource().getSensor();
+                    unitToReport.sensors.add(new SensorToReport(
+                            sensor.getName(), sensor.getId(), observation.getTimestamp().toString(), status)
+                    );
+                }
+            }
+        }
+
+        Collection<UnitToReport> unitsToReport = units.values().stream()
+                .sorted(Comparator.comparing(e -> e.unit_result)).collect(Collectors.toList());
+        return template.execute(Map.of("units", unitsToReport));
+    }
+
+    private static final class UnitToReport {
+        String unit_name;
+        long unit_id;
+        StatusReport unit_result;
+        List<SensorToReport> sensors;
+        UnitToReport(String unit_name, long unit_id, StatusReport unit_result) {
+            this.unit_name = unit_name;
+            this.unit_id = unit_id;
+            this.unit_result = unit_result;
+            this.sensors = new ArrayList<>();
+        }
+        void updateStatus(StatusReport status) {
+            if (!status.equals(OK)) {
+                this.unit_result = FAIL;
+            }
+        }
+        public boolean isHasSensors() {
+            return !this.sensors.isEmpty();
+        }
+    }
+
+    private static final class SensorToReport {
+        String sensor_name;
+        long sensor_id;
+        String timestamp;
+        StatusReport sensor_result;
+        SensorToReport(String sensor_name, long sensor_id, String timestamp, StatusReport sensor_result){
+            this.sensor_name = sensor_name;
+            this.sensor_id = sensor_id;
+            this.timestamp = timestamp;
+            this.sensor_result = sensor_result;
+        }
+    }
+}

+ 18 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/HtmlTemplate.java

@@ -0,0 +1,18 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+import cz.senslog.watchdog.domain.Report;
+import cz.senslog.watchdog.messagebroker.MessageException;
+
+public abstract class HtmlTemplate {
+
+    protected final HtmlTemplateType templateType;
+    protected final HtmlTemplateExecutor template;
+
+    protected HtmlTemplate(HtmlTemplateType templateType) {
+        String templateName = templateType.name().toLowerCase();
+        this.templateType = templateType;
+        this.template = new HtmlTemplateExecutor(templateName);
+    }
+
+    public abstract String createMessage(Report report) throws MessageException;
+}

+ 39 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/HtmlTemplateExecutor.java

@@ -0,0 +1,39 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+import com.github.mustachejava.DefaultMustacheFactory;
+import com.github.mustachejava.Mustache;
+import com.github.mustachejava.MustacheFactory;
+import cz.senslog.watchdog.app.Application;
+import cz.senslog.watchdog.messagebroker.MessageException;
+
+import java.io.*;
+import java.util.Map;
+
+public final class HtmlTemplateExecutor {
+    private static final String TEMPLATES_DIR = "templates";
+    private final Mustache template;
+
+    HtmlTemplateExecutor(String templateName) {
+        String templatePath = String.format("%s/%s.mustache", TEMPLATES_DIR, templateName);
+        ClassLoader cl = getClass().getClassLoader();
+        InputStream rs = cl.getResourceAsStream(templatePath);
+        if (rs == null) {
+            throw new RuntimeException(String.format("Template '%s' was not loaded correctly.", templatePath));
+        }
+        Reader reader = new InputStreamReader(rs);
+        MustacheFactory mf = new DefaultMustacheFactory();
+        this.template = mf.compile(reader, templateName);
+    }
+
+    public String execute(Map<String, Object> scopes) throws MessageException {
+        try {
+            Writer writer = new StringWriter();
+            template.execute(writer, new Object[]{scopes, Map.of("__opt", Map.of(
+                    "version", Application.COMPILED_VERSION
+            ).entrySet())}).flush();
+            return writer.toString();
+        } catch (IOException e) {
+            throw new MessageException(e.getMessage());
+        }
+    }
+}

+ 18 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/HtmlTemplateManager.java

@@ -0,0 +1,18 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class HtmlTemplateManager {
+
+    private static final Map<HtmlTemplateType, HtmlTemplate> TEMPLATES;
+
+    static {
+        TEMPLATES = new HashMap<>(HtmlTemplateType.values().length);
+        TEMPLATES.put(HtmlTemplateType.DEFAULT, new DefaultHtmlTemplate());
+    }
+
+    public static HtmlTemplate getTemplate(HtmlTemplateType templateType) {
+        return TEMPLATES.getOrDefault(templateType, null); // TODO create error template
+    }
+}

+ 19 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/HtmlTemplateType.java

@@ -0,0 +1,19 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+public enum HtmlTemplateType {
+    DEFAULT
+
+
+    ;
+    public static HtmlTemplateType of(String value) {
+        if (value == null) {
+            return null;
+        }
+        for (HtmlTemplateType type : values()) {
+            if (type.name().equalsIgnoreCase(value)) {
+                return type;
+            }
+        }
+        return null;
+    }
+}

+ 269 - 0
src/main/java/cz/senslog/watchdog/messagebroker/template/ResultBarHtmlTemplate.java

@@ -0,0 +1,269 @@
+package cz.senslog.watchdog.messagebroker.template;
+
+import cz.senslog.watchdog.domain.*;
+import cz.senslog.watchdog.messagebroker.MessageException;
+import cz.senslog.watchdog.messagebroker.writer.HtmlTableWriter;
+import cz.senslog.watchdog.messagebroker.writer.TableWriter;
+import cz.senslog.watchdog.util.Tuple;
+
+import java.util.*;
+
+import static cz.senslog.watchdog.domain.StatusReport.*;
+
+public class ResultBarHtmlTemplate extends HtmlTemplate {
+
+    private static final String BREAK_LINE = "<br />";
+    private static final String HORIZONTAL_SEPARATOR = "<hr>";
+    private static final String SUBSTITUTABLE_VARIABLE_PREFIX = "$";
+
+    public ResultBarHtmlTemplate() {
+        super(HtmlTemplateType.DEFAULT);
+    }
+
+    private String demoMessage(Report report) throws MessageException {
+        Map<String, Object> data = new HashMap<>();
+
+        List<Map<String, Object>> units = new ArrayList<>();
+        data.put("units", units);
+
+        List<Map<String, Object>> us2 = new ArrayList<>();
+        Map<String, Object> s2 = new HashMap<>();
+        s2.put("sensor_name", "Flood 2");
+        s2.put("sensor_id", 330180331);
+        s2.put("timestamp", "2022-12-02T21:00:00Z");
+        s2.put("sensor_result", "failure");
+        us2.add(s2);
+
+        Map<String, Object> unit1 = new HashMap<>();
+        unit1.put("unit_name", "Teros11 - Agronode");
+        unit1.put("unit_id", 4151889);
+        unit1.put("unit_result", "success");
+        unit1.put("sensors", us2);
+        units.add(unit1);
+
+        List<Map<String, Object>> us1 = new ArrayList<>();
+        Map<String, Object> s1 = new HashMap<>();
+        s1.put("sensor_name", "Flood");
+        s1.put("sensor_id", 330180001);
+        s1.put("timestamp", "2022-12-02T21:00:00Z");
+        s1.put("sensor_result", "failure");
+        us1.add(s1);
+
+
+        Map<String, Object> unit2 = new HashMap<>();
+        unit2.put("unit_name", "Teros11 - Agronode_0.5_S_0015918");
+        unit2.put("unit_id", 4151889);
+        unit2.put("unit_result", "failure");
+        unit2.put("sensors", us1);
+        units.add(unit2);
+
+        return template.execute(data);
+    }
+
+    @Override
+    public String createMessage(Report report) throws MessageException {
+
+        Map<Unit, List<ObservationInfo>> unitsToReport = new HashMap<>();
+        Map<Unit, Tuple<Unit, int[]>> unitToStatus = new HashMap<>();
+
+        for (SimpleReport simpleReport : report.getReports()) {
+            StatusReport status = simpleReport.getStatus();
+            boolean isOk = status.equals(OK);
+            if (simpleReport.getRecord() instanceof ObservationInfo) {
+                ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
+                Unit unit = observation.getSource().getUnit();
+
+                int[] statusReportCounters = unitToStatus.computeIfAbsent(unit,
+                                u -> Tuple.of(u, new int[StatusReport.values().length]))
+                        .getItem2();
+
+                statusReportCounters[status.ordinal()] += 1;
+
+                List<ObservationInfo> observations = unitsToReport.computeIfAbsent(unit, u -> new ArrayList<>());
+
+                if (!isOk) {
+                    observations.add(observation);
+                }
+            }
+        }
+
+        return "";
+    }
+
+
+
+    private String create(Report report) {
+        StringBuilder content = new StringBuilder();
+        final String rowStyle = "border: 1px solid #dddddd; text-align: left; padding: 8px;";
+
+        Map<String, String> operationProperties = report.getOperationProperties();
+        if (!operationProperties.isEmpty()) {
+            TableWriter tableSourceWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
+                    .cell("Operation Type").cell("Operation Value").end();
+
+            for (Map.Entry<String, String> operationEntry : operationProperties.entrySet()) {
+                tableSourceWriter.row(rowStyle)
+                        .cell(operationEntry.getKey(), rowStyle)
+                        .cell(operationEntry.getValue(), rowStyle)
+                        .end();
+            }
+            content.append(tableSourceWriter.table()).append(BREAK_LINE);
+        }
+
+        boolean isMessages = !report.getMessages().isEmpty();
+        boolean isRecords = !report.getReports().isEmpty();
+
+        if (isMessages || !isRecords) {
+            TableWriter tableMsgWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
+                    .cell("Messages").end();
+
+            for (String message : report.getMessages()) {
+                tableMsgWriter.row(rowStyle).cell(message, rowStyle).end();
+            }
+
+            if (!isRecords) {
+                tableMsgWriter.row(rowStyle).cell("All received observations are valid.");
+            }
+
+            content.append(tableMsgWriter.table()).append(BREAK_LINE);
+        }
+
+        if (isRecords) {
+
+            boolean renderReportTable = false;
+            Map<Unit, List<ObservationInfo>> unitsToReport = new HashMap<>();
+            Map<Unit, Tuple<Unit, int[]>> unitToStatus = new HashMap<>();
+            for (SimpleReport simpleReport : report.getReports()) {
+                StatusReport status = simpleReport.getStatus();
+                boolean isOk = status.equals(OK);
+                if (simpleReport.getRecord() instanceof ObservationInfo) {
+                    ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
+                    Unit unit = observation.getSource().getUnit();
+
+                    int[] statusReportCounters = unitToStatus.computeIfAbsent(unit,
+                                    u -> Tuple.of(u, new int[StatusReport.values().length]))
+                            .getItem2();
+
+                    statusReportCounters[status.ordinal()] += 1;
+
+                    List<ObservationInfo> observations = unitsToReport.computeIfAbsent(unit, u -> new ArrayList<>());
+
+                    if (!isOk) {
+                        observations.add(observation);
+                        renderReportTable = true;
+                    }
+                }
+            }
+
+            TableWriter tableUnitStatusWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
+                    .cell("unitName").cell("unitId").end();
+
+            List<Tuple<Unit, int[]>> statuses = new ArrayList<>(unitToStatus.values());
+            statuses.sort(Comparator.comparing(e -> e.getItem2()[OK.ordinal()] != 0));
+            for (Tuple<Unit, int[]> unitEntry : statuses) {
+                Unit unit = unitEntry.getItem1();
+
+                int[] statusProgress = createStatusProgressForUnit(unitEntry.getItem2());
+                int percentOK = statusProgress[OK.ordinal()];
+                int percentFAIL = statusProgress[FAIL.ordinal()] + statusProgress[OK.ordinal()];
+                int percentNODATA = statusProgress[NO_DATA.ordinal()] + percentFAIL;
+
+//                String color = String.format("linear-gradient(135deg, #CCFFCC %d%%, #FFCCCC 0 %d%%, white 0 %d%%)",
+//                        percentOK, percentFAIL,  percentNODATA);
+//                String backgroundColor = "background: " + color;
+
+                String color = percentOK == 100 ? "#CCFFCC" : "#FFCCCC";
+                String backgroundColor = "background-color: " + color;
+
+                tableUnitStatusWriter.row(rowStyle + backgroundColor)
+                        .cell(unit.getName(), rowStyle)
+                        .cell(String.valueOf(unit.getId()), rowStyle)
+                        .end();
+            }
+
+            content.append(tableUnitStatusWriter.table());
+
+            if (renderReportTable) {
+
+                content.append(HORIZONTAL_SEPARATOR).append(HORIZONTAL_SEPARATOR).append(BREAK_LINE);
+
+                TableWriter tableReportWriter = HtmlTableWriter.createWithHeader("width: 100%;", "background-color: #dddddd")
+                        .cell("unitName (unitId)").cell("sensorName (sensorId)").cell("timestamp").end();
+
+
+                for (Map.Entry<Unit, List<ObservationInfo>> unitEntry : unitsToReport.entrySet()) {
+                    for (ObservationInfo observation : unitEntry.getValue()) {
+                        Source source = observation.getSource();
+                        Unit unit = source.getUnit();
+                        Sensor sensor = source.getSensor();
+
+                        String unitCell = String.format("%s (%s)", unit.getName(), unit.getId());
+                        String sensorCell = String.format("%s (%s)", sensor.getName(), sensor.getId());
+
+                        tableReportWriter.row(rowStyle + "background-color: #FFCCCC")
+                                .cell(unitCell, rowStyle)
+                                .cell(sensorCell, rowStyle)
+                                .cell(observation.getTimestamp().toString(), rowStyle)
+                                .end();
+                    }
+
+                    tableReportWriter.emptyRow().emptyRow().emptyRow();
+
+                }
+
+            /*
+            report.getReports().sort(Comparator.comparing(SimpleReport::getStatus).reversed());
+            for (SimpleReport simpleReport : report.getReports()) {
+                boolean isOk = simpleReport.getStatus().equals(OK);
+                // ALLOW ONLY NOT OK REPORTS -> // TODO create a template system
+                if (!isOk && simpleReport.getRecord() instanceof ObservationInfo) {
+                    ObservationInfo observation = (ObservationInfo) simpleReport.getRecord();
+                    Source source = observation.getSource();
+
+                    String unitCell = String.format("%s (%s)", source.getUnit().getName(), source.getUnit().getId());
+                    String sensorCell = String.format("%s (%s)", source.getSensor().getName(), source.getSensor().getId());
+
+                    tableReportWriter.row(rowStyle + "background-color: " + (isOk ? "#CCFFCC" : "#FFCCCC"))
+                            .cell(unitCell, rowStyle)
+                            .cell(sensorCell, rowStyle)
+                            .cell(observation.getTimestamp().toString(), rowStyle)
+                          //  .cell(reportedTime, rowStyle)
+                          //  .cell(simpleReport.getStatus().name(), rowStyle)
+                            .end();
+                }
+            }
+             */
+
+                content.append(tableReportWriter.table()).append(BREAK_LINE);
+            }
+        }
+
+        return content.toString();
+    }
+
+    private static int[] createStatusProgressForUnit(int[] statuses) {
+        int[] statusToColor = new int[statuses.length];
+
+        int max = 0;
+        for (int status : statuses) {
+            max += status;
+        }
+
+        int maxPercent = 100;
+        for (int i = 0; i < statuses.length; i++) {
+            int percent = (int) ((double) statuses[i] / (double) max * 100.0);
+            statusToColor[i] = percent;
+            maxPercent -= percent;
+        }
+
+        if (maxPercent != 0) {
+            for (int i = statusToColor.length - 1; i >= 0; i--) {
+                if (statusToColor[i] != 0) {
+                    statusToColor[i] += maxPercent;
+                }
+            }
+        }
+
+        return statusToColor;
+    }
+}

+ 83 - 0
src/main/resources/templates/default.mustache

@@ -0,0 +1,83 @@
+<html>
+<head>
+
+<style>
+footer p {
+    font-size: 0.75em;
+}
+
+table {
+    width: 100%;
+}
+
+.table_header {
+    background-color: #dddddd;
+}
+
+.table_row_OK {
+    border: 1px solid #dddddd;
+    text-align: left;
+    padding: 8px;
+    background-color: #CCFFCC;
+}
+
+.table_row_FAIL {
+    border: 1px solid #dddddd;
+    text-align: left;
+    padding: 8px;
+    background-color: #FFCCCC;
+}
+
+.table_row_cell {
+    border: 1px solid #dddddd;
+    text-align: left;
+    padding: 8px;
+}
+
+tr.table_row_space>td {
+  padding-bottom: 1em;
+}
+
+</style>
+</head>
+
+<body>
+<table>
+	<tr class='table_header'>
+		<th>unitName</th>
+		<th>unitId</th>
+	</tr>
+	{{#units}}
+        <tr class='table_row_{{unit_result}}'>
+            <td class='table_row_cell'>{{unit_name}}</td>
+            <td class='table_row_cell'>{{unit_id}}</td>
+        </tr>
+	{{/units}}
+</table>
+&nbsp;
+<table>
+	<tr class='table_header'>
+		<th>unitName (unitId)</th>
+		<th>sensorName (sensorId)</th>
+		<th>timestamp</th>
+	</tr>
+	{{#units}}
+        {{#hasSensors}}
+	        {{#sensors}}
+            <tr class='table_row_{{sensor_result}}'>
+                <td class='table_row_cell'>{{unit_name}} ({{unit_id}})</td>
+                <td class='table_row_cell'>{{sensor_name}} ({{sensor_id}})</td>
+                <td class='table_row_cell'>{{timestamp}}</td>
+            </tr>
+            {{/sensors}}
+            <tr class='table_row_space'><td></td></tr>
+        {{/hasSensors}}
+	{{/units}}
+</table>
+<br/>
+</body>
+<footer>
+<hr><p>{{#__opt}}{{key}}: {{value}} | {{/__opt}}</p><hr>
+</footer>
+</html>
+