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 @@
{{$.Title}}
-
{{$.GeneratedDate}}
+
{{$.GeneratedDate}}{{if $.Timezone}} ({{$.Timezone}}){{end}}
@@ -415,7 +415,7 @@ @@ -429,7 +429,7 @@
{{.Title}}
-
{{.GeneratedDate}}
+
{{.GeneratedDate}}{{if .Timezone}} ({{.Timezone}}){{end}}
@@ -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 +}