mirror of
https://github.com/absmach/supermq.git
synced 2026-06-23 04:00:27 +00:00
NOISSUE - Add timezone support for reports (#329)
* add timezone support Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * update supermq Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * revert env variable 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> * revert env variable Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> * add reports title for context Signed-off-by: nyagamunene <stevenyaga2014@gmail.com> --------- Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>
This commit is contained in:
@@ -355,7 +355,7 @@
|
||||
<div class="header-content">
|
||||
<div style="width: 100px;"></div>
|
||||
<div class="header-title">{{$.Title}}</div>
|
||||
<div class="header-date">{{$.GeneratedDate}}</div>
|
||||
<div class="header-date">{{$.GeneratedDate}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
|
||||
</div>
|
||||
<div class="header-separator"></div>
|
||||
</div>
|
||||
@@ -415,7 +415,7 @@
|
||||
<div class="footer">
|
||||
<div class="footer-separator"></div>
|
||||
<div class="footer-content">
|
||||
<div class="footer-generated">Generated: {{$.GeneratedTime}}</div>
|
||||
<div class="footer-generated">Generated: {{$.GeneratedTime}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
|
||||
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +429,7 @@
|
||||
<div class="header-content">
|
||||
<div style="width: 100px;"></div>
|
||||
<div class="header-title">{{.Title}}</div>
|
||||
<div class="header-date">{{.GeneratedDate}}</div>
|
||||
<div class="header-date">{{.GeneratedDate}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
|
||||
</div>
|
||||
<div class="header-separator"></div>
|
||||
</div>
|
||||
@@ -477,7 +477,7 @@
|
||||
<div class="footer">
|
||||
<div class="footer-separator"></div>
|
||||
<div class="footer-content">
|
||||
<div class="footer-generated">Generated: {{.GeneratedTime}}</div>
|
||||
<div class="footer-generated">Generated: {{.GeneratedTime}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
|
||||
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,10 @@ if [ ! -f /opt/openbao/data/configured ]; then
|
||||
key_usage=\"DigitalSignature,KeyEncipherment,KeyAgreement\" \
|
||||
ext_key_usage=\"ServerAuth,ClientAuth,OCSPSigning\" \
|
||||
use_csr_common_name=true \
|
||||
use_csr_sans=false \
|
||||
use_csr_sans=true \
|
||||
copy_extensions=true \
|
||||
allowed_extensions=\"*\" \
|
||||
basic_constraints_valid_for_non_ca=true \
|
||||
max_ttl=720h \
|
||||
ttl=720h"
|
||||
|
||||
@@ -284,6 +287,9 @@ path "pki_int/issue/${AM_CERTS_OPENBAO_PKI_ROLE}" {
|
||||
path "pki_int/sign/${AM_CERTS_OPENBAO_PKI_ROLE}" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
path "pki_int/sign-verbatim/${AM_CERTS_OPENBAO_PKI_ROLE}" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
path "pki_int/certs" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
+48
-9
@@ -10,36 +10,61 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // Embed timezone database
|
||||
|
||||
pkglog "github.com/absmach/magistrala/pkg/logger"
|
||||
"github.com/absmach/supermq/pkg/errors"
|
||||
svcerr "github.com/absmach/supermq/pkg/errors/service"
|
||||
"github.com/absmach/supermq/pkg/transformers/senml"
|
||||
)
|
||||
|
||||
const nanosecondThreshold = float64(10 * time.Second / time.Nanosecond)
|
||||
|
||||
type ReportData struct {
|
||||
Title string
|
||||
GeneratedTime string
|
||||
GeneratedDate string
|
||||
Reports []Report
|
||||
Timezone string
|
||||
}
|
||||
|
||||
func (r *report) generatePDFReport(ctx context.Context, title string, reports []Report, template ReportTemplate) ([]byte, error) {
|
||||
func (r *report) generatePDFReport(ctx context.Context, title string, reports []Report, template ReportTemplate, timezone string) ([]byte, error) {
|
||||
for i := range reports {
|
||||
sort.Slice(reports[i].Messages, func(j, k int) bool {
|
||||
return reports[i].Messages[j].Time < reports[i].Messages[k].Time
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
loc, err := resolveTimezone(timezone)
|
||||
if err != nil {
|
||||
r.runInfo <- pkglog.RunInfo{
|
||||
Level: slog.LevelWarn,
|
||||
Message: fmt.Sprintf("failed to resolve timezone '%s', falling back to UTC: %s", timezone, err),
|
||||
Details: []slog.Attr{
|
||||
slog.String("report_title", title),
|
||||
slog.Time("time", time.Now().UTC()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
displayTZ := timezone
|
||||
if strings.TrimSpace(displayTZ) == "" {
|
||||
displayTZ = "UTC"
|
||||
}
|
||||
|
||||
data := ReportData{
|
||||
Title: title,
|
||||
GeneratedTime: now.Format("15:04:05"),
|
||||
GeneratedDate: now.Format("02 Jan 2006"),
|
||||
Reports: reports,
|
||||
Timezone: displayTZ,
|
||||
}
|
||||
|
||||
templateContent := r.defaultTemplate.String()
|
||||
@@ -51,7 +76,7 @@ func (r *report) generatePDFReport(ctx context.Context, title string, reports []
|
||||
|
||||
func (r *report) generate(ctx context.Context, templateContent string, data ReportData) ([]byte, error) {
|
||||
tmpl := template.New("report").Funcs(template.FuncMap{
|
||||
"formatTime": formatTime,
|
||||
"formatTime": func(t float64) string { return r.formatTimeWithTimezone(t, data.Timezone) },
|
||||
"formatValue": formatValue,
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
@@ -140,11 +165,25 @@ func (r *report) htmlToPDF(ctx context.Context, htmlContent string) ([]byte, err
|
||||
return pdfBytes, nil
|
||||
}
|
||||
|
||||
func formatTime(t float64) string {
|
||||
if t > 9999999999 {
|
||||
return time.Unix(0, int64(t)).Format("2006-01-02 15:04:05")
|
||||
func (r *report) formatTimeWithTimezone(t float64, timezone string) string {
|
||||
loc, err := resolveTimezone(timezone)
|
||||
if err != nil {
|
||||
r.runInfo <- pkglog.RunInfo{
|
||||
Level: slog.LevelWarn,
|
||||
Message: fmt.Sprintf("failed to resolve timezone '%s', falling back to UTC: %s", timezone, err),
|
||||
Details: []slog.Attr{slog.Time("time", time.Now().UTC())},
|
||||
}
|
||||
}
|
||||
return time.Unix(int64(t), 0).Format("2006-01-02 15:04:05")
|
||||
|
||||
var timeVal time.Time
|
||||
switch {
|
||||
case t > nanosecondThreshold:
|
||||
timeVal = time.Unix(0, int64(t)).In(loc)
|
||||
default:
|
||||
timeVal = time.Unix(int64(t), 0).In(loc)
|
||||
}
|
||||
|
||||
return timeVal.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatValue(msg senml.Message) string {
|
||||
@@ -162,7 +201,7 @@ func formatValue(msg senml.Message) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *report) generateCSVReport(_ context.Context, title string, reports []Report) ([]byte, error) {
|
||||
func (r *report) generateCSVReport(_ context.Context, title string, reports []Report, timezone string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
|
||||
@@ -217,7 +256,7 @@ func (r *report) generateCSVReport(_ context.Context, title string, reports []Re
|
||||
})
|
||||
|
||||
for _, msg := range report.Messages {
|
||||
timeStr := formatTime(msg.Time)
|
||||
timeStr := r.formatTimeWithTimezone(msg.Time, timezone)
|
||||
|
||||
var valueStr string
|
||||
if msg.Value != nil {
|
||||
|
||||
+8
-1
@@ -83,7 +83,8 @@ type MetricConfig struct {
|
||||
To string `json:"to,omitempty"` // Mandatory field
|
||||
Title string `json:"title,omitempty"` // Mandatory field
|
||||
|
||||
FileFormat Format `json:"file_format"` // Optional field
|
||||
FileFormat Format `json:"file_format"` // Optional field
|
||||
Timezone string `json:"timezone,omitempty"` // Optional field, defaults to UTC
|
||||
|
||||
Aggregation AggConfig `json:"aggregation,omitempty"` // Optional field
|
||||
}
|
||||
@@ -113,6 +114,12 @@ func (mc MetricConfig) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if tz := strings.TrimSpace(mc.Timezone); tz != "" {
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return errors.Wrap(fmt.Errorf("invalid timezone: %s", tz), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -162,8 +162,10 @@ func (r *report) DisableReportConfig(ctx context.Context, session authn.Session,
|
||||
func (r *report) GenerateReport(ctx context.Context, session authn.Session, config ReportConfig, action ReportAction) (ReportPage, error) {
|
||||
config.DomainID = session.DomainID
|
||||
|
||||
if config.Status != EnabledStatus {
|
||||
return ReportPage{}, svcerr.ErrInvalidStatus
|
||||
if action != ViewReport && action != DownloadReport && action != EmailReport {
|
||||
if config.Status != EnabledStatus {
|
||||
return ReportPage{}, svcerr.ErrInvalidStatus
|
||||
}
|
||||
}
|
||||
|
||||
reportPage, err := r.generateReport(ctx, config, action)
|
||||
@@ -175,7 +177,7 @@ func (r *report) GenerateReport(ctx context.Context, session authn.Session, conf
|
||||
}
|
||||
|
||||
func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action ReportAction) (ReportPage, error) {
|
||||
genReportFile, err := r.generateFileFunc(ctx, action, cfg.Config.FileFormat, cfg.ReportTemplate)
|
||||
genReportFile, err := r.generateFileFunc(ctx, action, cfg.Config.FileFormat, cfg.ReportTemplate, cfg.Config.Timezone)
|
||||
if err != nil {
|
||||
return ReportPage{}, err
|
||||
}
|
||||
@@ -327,17 +329,17 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re
|
||||
}
|
||||
}
|
||||
|
||||
func (r *report) generateFileFunc(_ context.Context, action ReportAction, format Format, customTemplate ReportTemplate) (func(context.Context, string, []Report) ([]byte, error), error) {
|
||||
func (r *report) generateFileFunc(_ context.Context, action ReportAction, format Format, customTemplate ReportTemplate, timezone string) (func(context.Context, string, []Report) ([]byte, error), error) {
|
||||
switch action {
|
||||
case DownloadReport, EmailReport:
|
||||
switch format {
|
||||
case PDF:
|
||||
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
|
||||
return r.generatePDFReport(ctx, title, reports, customTemplate)
|
||||
return r.generatePDFReport(ctx, title, reports, customTemplate, timezone)
|
||||
}, nil
|
||||
case CSV:
|
||||
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
|
||||
return r.generateCSVReport(ctx, title, reports)
|
||||
return r.generateCSVReport(ctx, title, reports, timezone)
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.New("file format not supported")
|
||||
|
||||
@@ -467,3 +467,104 @@ func TestDisableReportConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInstantEmailReport(t *testing.T) {
|
||||
svc, _, _ := newService(make(chan pkglog.RunInfo))
|
||||
|
||||
validEmailConfig := reports.EmailSetting{
|
||||
To: []string{"test@example.com"},
|
||||
Subject: "Test Report",
|
||||
Content: "Please find the attached report.",
|
||||
}
|
||||
|
||||
validConfig := reports.ReportConfig{
|
||||
ID: testsutil.GenerateUUID(&testing.T{}),
|
||||
Name: "Test Report",
|
||||
DomainID: domainID,
|
||||
Status: reports.DisabledStatus,
|
||||
Email: &validEmailConfig,
|
||||
Config: &reports.MetricConfig{
|
||||
Title: "Test Report",
|
||||
FileFormat: reports.PDF,
|
||||
From: "now-1h",
|
||||
To: "now",
|
||||
Aggregation: reports.AggConfig{
|
||||
AggType: reports.AggregationAVG,
|
||||
Interval: "1h",
|
||||
},
|
||||
},
|
||||
Metrics: []reports.ReqMetric{
|
||||
{
|
||||
ChannelID: testsutil.GenerateUUID(&testing.T{}),
|
||||
Name: "temperature",
|
||||
ClientIDs: []string{testsutil.GenerateUUID(&testing.T{})},
|
||||
},
|
||||
},
|
||||
ReportTemplate: template,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
session authn.Session
|
||||
config reports.ReportConfig
|
||||
action reports.ReportAction
|
||||
err error
|
||||
}{
|
||||
{
|
||||
desc: "Generate instant email report with disabled config should succeed",
|
||||
session: authn.Session{
|
||||
UserID: userID,
|
||||
DomainID: domainID,
|
||||
},
|
||||
config: validConfig,
|
||||
action: reports.EmailReport,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
desc: "Generate instant email report with enabled config should succeed",
|
||||
session: authn.Session{
|
||||
UserID: userID,
|
||||
DomainID: domainID,
|
||||
},
|
||||
config: func() reports.ReportConfig {
|
||||
cfg := validConfig
|
||||
cfg.Status = reports.EnabledStatus
|
||||
return cfg
|
||||
}(),
|
||||
action: reports.EmailReport,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
desc: "Generate view report with disabled config should succeed",
|
||||
session: authn.Session{
|
||||
UserID: userID,
|
||||
DomainID: domainID,
|
||||
},
|
||||
config: validConfig,
|
||||
action: reports.ViewReport,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
desc: "Generate download report with disabled config should succeed",
|
||||
session: authn.Session{
|
||||
UserID: userID,
|
||||
DomainID: domainID,
|
||||
},
|
||||
config: validConfig,
|
||||
action: reports.DownloadReport,
|
||||
err: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
_, err := svc.GenerateReport(context.Background(), tc.session, tc.config, tc.action)
|
||||
|
||||
if tc.err != nil {
|
||||
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
|
||||
} else {
|
||||
assert.False(t, errors.Contains(err, svcerr.ErrInvalidStatus), fmt.Sprintf("%s: should not get ErrInvalidStatus for instant reports, got %s\n", tc.desc, err))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package reports
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resolveTimezone returns a *time.Location from a user-provided IANA timezone name.
|
||||
// Supported inputs:
|
||||
// - IANA names (e.g., "Europe/Paris", "America/New_York").
|
||||
// - Empty string defaults to UTC.
|
||||
func resolveTimezone(s string) (*time.Location, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.UTC, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(s)
|
||||
if err != nil {
|
||||
return time.UTC, err
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
Reference in New Issue
Block a user