diff --git a/cmd/reports/template/reports_default_template.html b/cmd/reports/template/reports_default_template.html index 8152f3cc2..1785e0931 100644 --- a/cmd/reports/template/reports_default_template.html +++ b/cmd/reports/template/reports_default_template.html @@ -17,10 +17,6 @@ --text-primary: rgb(44, 62, 80); --text-secondary: rgb(127, 140, 141); --white: #ffffff; - - --header-height: 35mm; - --footer-height: 20mm; - --page-padding: 15mm; } * { @@ -37,40 +33,38 @@ } .page { - max-width: 210mm; - min-height: 297mm; - padding: var(--page-padding) 10mm; - margin: 5mm auto 0 auto; + width: 210mm; + height: 297mm; + padding: 15mm 10mm; + margin: 0 auto; background: var(--white); box-shadow: 0 0 10px rgba(0,0,0,0.1); position: relative; + page-break-after: always; display: flex; flex-direction: column; } + .page:last-child { + page-break-after: auto; + } + .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; + margin-bottom: 8mm; } .header-top-bar { height: 8px; background-color: var(--primary-color); - margin: 5px 0 10px 0; - flex-shrink: 0; + margin: 0 0 8px 0; } .header-content { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 10px; - flex-shrink: 0; + margin-bottom: 8px; } .header-title { @@ -92,9 +86,8 @@ .header-separator { height: 2px; background-color: var(--subtle-color); - margin: 5px 0 10px 0; + margin: 5px 0; position: relative; - flex-shrink: 0; } .header-separator::after { @@ -111,32 +104,35 @@ flex-grow: 1; display: flex; flex-direction: column; - min-height: 0; overflow: hidden; } .metrics-section { - margin-bottom: 15px; flex-shrink: 0; + margin-bottom: 10px; + } + + .metrics-section.continuation { + display: none; } .metrics-title { font-size: 16px; font-weight: bold; color: var(--secondary-color); - margin-bottom: 10px; + margin-bottom: 8px; } .metrics-info { background-color: var(--alternate-row); - padding: 12px; + padding: 10px; border-radius: 4px; - margin-bottom: 10px; + margin-bottom: 8px; } .metric-row { display: flex; - margin-bottom: 8px; + margin-bottom: 6px; } .metric-row:last-child { @@ -162,14 +158,13 @@ font-size: 10px; font-style: italic; color: var(--text-secondary); - margin-bottom: 10px; + margin-bottom: 8px; flex-shrink: 0; } .table-container { flex-grow: 1; - overflow: auto; - min-height: 0; + overflow: hidden; } .data-table { @@ -190,8 +185,6 @@ padding: 8px; text-align: center; border-bottom: 2px solid var(--subtle-color); - position: sticky; - top: 0; } .data-table td { @@ -237,23 +230,17 @@ } .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; + border-top: 2px solid var(--subtle-color); + padding-top: 6px; + margin-top: 8mm; } .footer-separator { height: 1px; background-color: var(--subtle-color); - margin-bottom: 6px; + margin-bottom: 4px; position: relative; - flex-shrink: 0; } .footer-separator::after { @@ -270,25 +257,18 @@ 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 { @@ -299,56 +279,59 @@ html, body { width: 210mm; - height: auto; - margin: 0 !important; - padding: 0 !important; + height: 297mm; + margin: 0; + padding: 0; } .page { - box-shadow: none; - max-width: none; - width: 210mm; - height: 297mm; - min-height: 297mm; margin: 0; - display: block; - page-break-after: always; - break-inside: avoid; + box-shadow: none; } - - .page:last-child { - page-break-after: auto; - } - - .header, .footer { - break-inside: avoid; - } - - .content-area { - overflow: hidden; - padding-bottom: calc(var(--footer-height) + 6mm); - } - - .footer { - position: absolute; - left: var(--page-padding); - right: var(--page-padding); - bottom: var(--page-padding); - } - - .table-container { overflow: hidden; } } - {{$totalPages := len .Reports}} - {{if eq $totalPages 0}} - {{$totalPages = 1}} - {{end}} - {{$globalPage := 0}} {{if gt (len .Reports) 0}} - {{range $index, $report := .Reports}} + {{$firstPageRows := 24}} + {{$continuationPageRows := 32}} + {{$totalPages := 0}} + + {{/* Calculate total pages across all reports */}} + {{range $report := .Reports}} + {{$totalMessages := len .Messages}} + {{$reportPages := 1}} + {{if gt $totalMessages $firstPageRows}} + {{$remaining := sub $totalMessages $firstPageRows}} + {{$additionalPages := div $remaining $continuationPageRows}} + {{if gt (mod $remaining $continuationPageRows) 0}} + {{$additionalPages = add $additionalPages 1}} + {{end}} + {{$reportPages = add 1 $additionalPages}} + {{end}} + {{$totalPages = add $totalPages $reportPages}} + {{end}} + + {{$globalPage := 0}} + + {{range $reportIndex, $report := .Reports}} + {{$totalMessages := len .Messages}} + {{$pageCount := 1}} + {{if gt $totalMessages $firstPageRows}} + {{$remaining := sub $totalMessages $firstPageRows}} + {{$additionalPages := div $remaining $continuationPageRows}} + {{if gt (mod $remaining $continuationPageRows) 0}} + {{$additionalPages = add $additionalPages 1}} + {{end}} + {{$pageCount = add 1 $additionalPages}} + {{end}} + + {{range $pageNum := iterate $pageCount}} {{$globalPage = add $globalPage 1}} + {{$isFirstPage := eq $pageNum 0}} + {{$startRow := getStartRow $pageNum $firstPageRows $continuationPageRows}} + {{$endRow := getEndRow $pageNum $firstPageRows $continuationPageRows $totalMessages}} +
@@ -361,29 +344,35 @@
+ {{if $isFirstPage}}
Metrics
Name:
-
{{.Metric.Name}}
+
{{$report.Metric.Name}}
- {{if .Metric.ClientID}} + {{if $report.Metric.ClientID}}
Device ID:
-
{{.Metric.ClientID}}
+
{{$report.Metric.ClientID}}
{{end}}
Channel ID:
-
{{.Metric.ChannelID}}
+
{{$report.Metric.ChannelID}}
- Total Records: {{len .Messages}} + Total Records: {{$totalMessages}}
+ {{else}} +
+
Metrics (continued)
+
+ {{end}}
@@ -398,15 +387,17 @@ - {{range .Messages}} + {{range $msgIndex, $msg := $report.Messages}} + {{if and (ge $msgIndex $startRow) (lt $msgIndex $endRow)}} - {{formatTime .Time}} - {{formatValue .}} - {{.Unit}} - {{.Protocol}} - {{.Subtopic}} + {{formatTime $msg.Time}} + {{formatValue $msg}} + {{$msg.Unit}} + {{$msg.Protocol}} + {{$msg.Subtopic}} {{end}} + {{end}}
@@ -421,8 +412,8 @@
{{end}} + {{end}} {{else}} - {{$globalPage = 1}}
@@ -440,11 +431,11 @@
Name:
-
{{.Metric.Name}}
+
No Report
Channel ID:
-
{{.Metric.ChannelID}}
+
N/A
@@ -478,7 +469,7 @@
diff --git a/pkg/reltime/reltime.go b/pkg/reltime/reltime.go index 96234c8e4..f77aea90f 100644 --- a/pkg/reltime/reltime.go +++ b/pkg/reltime/reltime.go @@ -22,7 +22,7 @@ var ( ) func Parse(expr string) (time.Time, error) { - now := time.Now() + now := time.Now().UTC() expr = strings.ReplaceAll(expr, " ", "") if strings.EqualFold(expr, "now()") { diff --git a/re/service.go b/re/service.go index 8b4044ac6..241fe6ea6 100644 --- a/re/service.go +++ b/re/service.go @@ -49,7 +49,7 @@ func (re *re) AddRule(ctx context.Context, session authn.Session, r Rule) (Rule, if err != nil { return Rule{}, err } - now := time.Now() + now := time.Now().UTC() r.CreatedAt = now r.ID = id r.CreatedBy = session.UserID diff --git a/reports/generator.go b/reports/generator.go index 2167a748d..a110fa0a4 100644 --- a/reports/generator.go +++ b/reports/generator.go @@ -80,6 +80,24 @@ func (r *report) generate(ctx context.Context, templateContent string, data Repo "formatValue": formatValue, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, + "iterate": func(count int) []int { return makeRange(count) }, + "ge": func(a, b int) bool { return a >= b }, + "lt": func(a, b int) bool { return a < b }, + "eq": func(a, b int) bool { return a == b }, + "div": func(a, b int) int { + if b == 0 { + return 0 + } + return a / b + }, + "mod": func(a, b int) int { + if b == 0 { + return 0 + } + return a % b + }, + "getStartRow": getStartRow, + "getEndRow": getEndRow, }) tmpl, err := tmpl.Parse(templateContent) @@ -201,6 +219,36 @@ func formatValue(msg senml.Message) string { } } +func makeRange(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + } + return result +} + +func getStartRow(pageNum, firstPageRows, continuationPageRows int) int { + if pageNum == 0 { + return 0 + } + return firstPageRows + (pageNum-1)*continuationPageRows +} + +func getEndRow(pageNum, firstPageRows, continuationPageRows, totalMessages int) int { + var end int + if pageNum == 0 { + end = firstPageRows + } else { + start := firstPageRows + (pageNum-1)*continuationPageRows + end = start + continuationPageRows + } + + if end > totalMessages { + end = totalMessages + } + return end +} + func (r *report) generateCSVReport(_ context.Context, title string, reports []Report, timezone string) ([]byte, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) diff --git a/reports/service.go b/reports/service.go index 09c39dc0e..fdba0a32e 100644 --- a/reports/service.go +++ b/reports/service.go @@ -6,6 +6,7 @@ package reports import ( "context" "fmt" + "log/slog" "strings" "time" @@ -196,18 +197,35 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re agg = grpcReadersV1.Aggregation_SUM } + loc, err := resolveTimezone(cfg.Config.Timezone) + if err != nil { + r.runInfo <- pkglog.RunInfo{ + Level: slog.LevelWarn, + Message: fmt.Sprintf("failed to resolve timezone '%s', falling back to UTC: %s", cfg.Config.Timezone, err), + Details: []slog.Attr{ + slog.String("report_name", cfg.Name), + slog.String("timezone", cfg.Config.Timezone), + }, + } + } + from, err := reltime.Parse(cfg.Config.From) if err != nil { return ReportPage{}, err } + to, err := reltime.Parse(cfg.Config.To) if err != nil { return ReportPage{}, err } + + fromDisplay := from.In(loc) + toDisplay := to.In(loc) + pm := &grpcReadersV1.PageMetadata{ Aggregation: agg, Limit: limit, - From: float64(from.UnixMicro()), + From: float64(from.UnixNano()), To: float64(to.UnixNano()), Interval: cfg.Config.Aggregation.Interval, } @@ -320,8 +338,8 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re default: return ReportPage{ - From: from, - To: to, + From: fromDisplay, + To: toDisplay, Aggregation: cfg.Config.Aggregation, Total: uint64(len(reports)), Reports: reports, diff --git a/reports/template.go b/reports/template.go index 6e360c2ea..0d655b56e 100644 --- a/reports/template.go +++ b/reports/template.go @@ -35,7 +35,26 @@ func (temp ReportTemplate) Validate() error { // Validate template syntax using Go's template parser tmpl := template.New("validate").Funcs(template.FuncMap{ - "add": func(a, b int) int { return a + b }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "div": func(a, b int) int { + if b == 0 { + return 0 + } + return a / b + }, + "mod": func(a, b int) int { + if b == 0 { + return 0 + } + return a % b + }, + "eq": func(a, b int) bool { return a == b }, + "ge": func(a, b int) bool { return a >= b }, + "lt": func(a, b int) bool { return a < b }, + "iterate": func(count int) []int { return make([]int, count) }, + "getStartRow": func(pageNum, firstPageRows, continuationPageRows int) int { return 0 }, + "getEndRow": func(pageNum, firstPageRows, continuationPageRows, totalMessages int) int { return 0 }, "formatTime": func(t any) string { return "" }, "formatValue": func(v any) string { return "" }, }) @@ -55,7 +74,7 @@ func (temp ReportTemplate) Validate() error { return fmt.Errorf("missing essential template field: {{$.Title}}") } if !hasRange { - return fmt.Errorf("missing essential template field: {{range .Messages}}") + return fmt.Errorf("missing essential template field: {{range .Messages}} or {{range .Reports}}") } if !hasFormatTime { return fmt.Errorf("missing essential template field: {{formatTime .Time}}") @@ -105,7 +124,8 @@ func validateEssentialFields(node parse.Node, hasTitle, hasRange, hasFormatTime, case *parse.RangeNode: if n.Pipe != nil && len(n.Pipe.Cmds) > 0 { cmdStr := n.Pipe.Cmds[0].String() - if cmdStr == ".Messages" { + // Accept .Messages, .Reports, or $report.Messages + if cmdStr == ".Messages" || cmdStr == ".Reports" || cmdStr == "$report.Messages" { *hasRange = true } }