mirror of
https://github.com/absmach/supermq.git
synced 2026-06-23 07:20:19 +00:00
MG-134 - Add support for Report templates (#180)
* initial implementation Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * initial implementation Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * add remove report from nats handler Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix failing linter Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * address comments Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * move runinfo to pkg Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update report handler Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update reports handler Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update handler in reports Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update go.mod and go.sum Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update package to chromedp Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * change update reorts to use chromium Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update report template Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * add endpoint and repo methods Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * add template validation Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * remove repeated code Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix failing linter Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix failing linter Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix template formatting Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update report template Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix mocks and tests Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * remove debug logs Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix api docs Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * remove pointers Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix template path Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix template path Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * address comments Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix failing validation Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * address comments Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * failing linter Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix logic Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * address comments Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * revert ui variable changes Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * rename method Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update to browserless service Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix failing linter Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * fix go mod file Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * address comments Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> --------- Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>
This commit is contained in:
+42
-11
@@ -1,11 +1,12 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package main contains rule engine main function to start the service.
|
||||
// Package main contains reports main function to start the service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -50,19 +51,25 @@ const (
|
||||
defSvcHTTPPort = "9017"
|
||||
envPrefixGrpc = "MG_TIMESCALE_READER_GRPC_"
|
||||
envPrefixDomains = "SMQ_DOMAINS_GRPC_"
|
||||
templatePath = "template/reports_default_template.html"
|
||||
)
|
||||
|
||||
// We use a buffered channel to prevent blocking, as logging is an expensive operation.
|
||||
const channBuffer = 256
|
||||
|
||||
//go:embed template/reports_default_template.html
|
||||
var templateFS embed.FS
|
||||
|
||||
type config struct {
|
||||
LogLevel string `env:"MG_REPORTS_LOG_LEVEL" envDefault:"info"`
|
||||
InstanceID string `env:"MG_REPORTS_INSTANCE_ID" envDefault:""`
|
||||
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
|
||||
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
|
||||
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
|
||||
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
|
||||
BrokerURL string `env:"SMQ_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
|
||||
LogLevel string `env:"MG_REPORTS_LOG_LEVEL" envDefault:"info"`
|
||||
InstanceID string `env:"MG_REPORTS_INSTANCE_ID" envDefault:""`
|
||||
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
|
||||
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
|
||||
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
|
||||
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
|
||||
BrokerURL string `env:"SMQ_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
|
||||
DefaultTemplatePath string `env:"MG_REPORTS_DEFAULT_TEMPLATE" envDefault:""`
|
||||
ConverterURL string `env:"MG_REPORT_BROWSERLESS_URL" envDefault:"http://localhost:4000/pdf"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -91,6 +98,30 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
var templateData []byte
|
||||
|
||||
switch cfg.DefaultTemplatePath {
|
||||
case "":
|
||||
templateData, err = templateFS.ReadFile(templatePath)
|
||||
default:
|
||||
templateData, err = os.ReadFile(templatePath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("failed to read report template: %s", err))
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
template := reports.ReportTemplate(string(templateData))
|
||||
|
||||
if err := template.Validate(); err != nil {
|
||||
logger.Error(fmt.Sprintf("failed to validate report template: %s", err))
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
logger.Info("Report template validated successfully")
|
||||
|
||||
ec := email.Config{}
|
||||
if err := env.Parse(&ec); err != nil {
|
||||
logger.Error(fmt.Sprintf("failed to load email configuration : %s", err))
|
||||
@@ -198,7 +229,7 @@ func main() {
|
||||
|
||||
runInfo := make(chan pkglog.RunInfo, channBuffer)
|
||||
|
||||
svc, err := newService(database, runInfo, authz, ec, logger, readersClient)
|
||||
svc, err := newService(database, runInfo, authz, ec, logger, readersClient, template, cfg.ConverterURL)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("failed to create services: %s", err))
|
||||
exitCode = 1
|
||||
@@ -238,7 +269,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz.Authorization, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient) (reports.Service, error) {
|
||||
func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz.Authorization, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient, template reports.ReportTemplate, converterURL string) (reports.Service, error) {
|
||||
repo := repg.NewRepository(db)
|
||||
idp := uuid.New()
|
||||
|
||||
@@ -247,7 +278,7 @@ func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz
|
||||
logger.Error(fmt.Sprintf("failed to configure e-mailing util: %s", err.Error()))
|
||||
}
|
||||
|
||||
csvc := reports.NewService(repo, runInfo, idp, ticker.NewTicker(time.Second*30), emailerClient, readersClient)
|
||||
csvc := reports.NewService(repo, runInfo, idp, ticker.NewTicker(time.Second*30), emailerClient, readersClient, template, converterURL)
|
||||
csvc, err = middleware.AuthorizationMiddleware(csvc, authz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
<!-- Copyright (c) Abstract Machines -->
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: rgb(41, 128, 185);
|
||||
--secondary-color: rgb(26, 82, 118);
|
||||
--subtle-color: rgb(189, 195, 199);
|
||||
--table-header-bg: rgb(236, 240, 241);
|
||||
--alternate-row: rgb(245, 247, 249);
|
||||
--text-primary: rgb(44, 62, 80);
|
||||
--text-secondary: rgb(127, 140, 141);
|
||||
--white: #ffffff;
|
||||
|
||||
--header-height: 35mm;
|
||||
--footer-height: 20mm;
|
||||
--page-padding: 15mm;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: var(--white);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: var(--page-padding) 10mm;
|
||||
margin: 5mm auto 0 auto;
|
||||
background: var(--white);
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-top-bar {
|
||||
height: 8px;
|
||||
background-color: var(--primary-color);
|
||||
margin: 0 -10mm 15px -10mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.header-separator {
|
||||
height: 2px;
|
||||
background-color: var(--subtle-color);
|
||||
margin: 5px 0 10px 0;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-separator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--subtle-color);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metrics-section {
|
||||
margin-bottom: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metrics-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--secondary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metrics-info {
|
||||
background-color: var(--alternate-row);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
width: 120px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-style: italic;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table-header-bar {
|
||||
height: 4px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--secondary-color);
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--subtle-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) {
|
||||
background-color: var(--alternate-row);
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: rgba(41, 128, 185, 0.05);
|
||||
}
|
||||
|
||||
.col-time {
|
||||
width: 25%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.col-value {
|
||||
width: 17%;
|
||||
color: var(--text-primary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.col-unit {
|
||||
width: 17%;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.col-protocol {
|
||||
width: 17%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.col-subtopic {
|
||||
width: 24%;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: var(--footer-height);
|
||||
min-height: var(--footer-height);
|
||||
max-height: var(--footer-height);
|
||||
border-top: 2px solid var(--subtle-color);
|
||||
padding-top: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
height: 1px;
|
||||
background-color: var(--subtle-color);
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-separator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--subtle-color);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-generated {
|
||||
font-size: 8px;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer-page {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.page {
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
height: 297mm;
|
||||
min-height: auto;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.page:last-child {
|
||||
page-break-after: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{$totalPages := len .Reports}}
|
||||
{{$globalPage := 0}}
|
||||
{{range $index, $report := .Reports}}
|
||||
{{$globalPage = add $globalPage 1}}
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div class="header-top-bar"></div>
|
||||
<div class="header-content">
|
||||
<div style="width: 100px;"></div>
|
||||
<div class="header-title">{{$.Title}}</div>
|
||||
<div class="header-date">{{$.GeneratedDate}}</div>
|
||||
</div>
|
||||
<div class="header-separator"></div>
|
||||
</div>
|
||||
|
||||
<div class="content-area">
|
||||
<div class="metrics-section">
|
||||
<div class="metrics-title">Metrics</div>
|
||||
<div class="metrics-info">
|
||||
<div class="metric-row">
|
||||
<div class="metric-label">Name:</div>
|
||||
<div class="metric-value">{{.Metric.Name}}</div>
|
||||
</div>
|
||||
{{if .Metric.ClientID}}
|
||||
<div class="metric-row">
|
||||
<div class="metric-label">Device ID:</div>
|
||||
<div class="metric-value">{{.Metric.ClientID}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="metric-row">
|
||||
<div class="metric-label">Channel ID:</div>
|
||||
<div class="metric-value">{{.Metric.ChannelID}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="record-count">
|
||||
Total Records: {{len .Messages}}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header-bar"></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">Time</th>
|
||||
<th class="col-value">Value</th>
|
||||
<th class="col-unit">Unit</th>
|
||||
<th class="col-protocol">Protocol</th>
|
||||
<th class="col-subtopic">Subtopic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Messages}}
|
||||
<tr>
|
||||
<td class="col-time">{{formatTime .Time}}</td>
|
||||
<td class="col-value">{{formatValue .}}</td>
|
||||
<td class="col-unit">{{.Unit}}</td>
|
||||
<td class="col-protocol">{{.Protocol}}</td>
|
||||
<td class="col-subtopic">{{.Subtopic}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-separator"></div>
|
||||
<div class="footer-content">
|
||||
<div class="footer-generated">Generated: {{$.GeneratedTime}}</div>
|
||||
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user