diff --git a/cmd/reports/template/reports_default_template.html b/cmd/reports/template/reports_default_template.html
index 1d50eee37..8152f3cc2 100644
--- a/cmd/reports/template/reports_default_template.html
+++ b/cmd/reports/template/reports_default_template.html
@@ -355,7 +355,7 @@
@@ -415,7 +415,7 @@
@@ -429,7 +429,7 @@
@@ -477,7 +477,7 @@
diff --git a/docker/supermq-docker/addons/certs/openbao-entrypoint.sh b/docker/supermq-docker/addons/certs/openbao-entrypoint.sh
index 36f629b30..69c262737 100755
--- a/docker/supermq-docker/addons/certs/openbao-entrypoint.sh
+++ b/docker/supermq-docker/addons/certs/openbao-entrypoint.sh
@@ -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"]
}
diff --git a/reports/generator.go b/reports/generator.go
index 6ca8e34fe..2167a748d 100644
--- a/reports/generator.go
+++ b/reports/generator.go
@@ -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 {
diff --git a/reports/reports.go b/reports/reports.go
index c8ff2efb5..b76a980e3 100644
--- a/reports/reports.go
+++ b/reports/reports.go
@@ -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
}
diff --git a/reports/service.go b/reports/service.go
index a59f18a4c..09c39dc0e 100644
--- a/reports/service.go
+++ b/reports/service.go
@@ -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")
diff --git a/reports/service_test.go b/reports/service_test.go
index 011524a8d..17b373a84 100644
--- a/reports/service_test.go
+++ b/reports/service_test.go
@@ -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))
+ }
+ })
+ }
+}
diff --git a/reports/tz.go b/reports/tz.go
new file mode 100644
index 000000000..d2e317686
--- /dev/null
+++ b/reports/tz.go
@@ -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
+}