NOISSUE - Fix Report time range display (#330)

* fx to and from

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* change to UTC

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix template pagination and address comment

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* revert env variable

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix pagination

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>

---------

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>
This commit is contained in:
Steve Munene
2025-10-09 17:28:15 +03:00
committed by GitHub
parent 8d4ead8e86
commit 5a6e0343dc
6 changed files with 187 additions and 110 deletions
@@ -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; }
}
</style>
</head>
<body>
{{$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}}
<div class="page">
<div class="header">
<div class="header-top-bar"></div>
@@ -361,29 +344,35 @@
</div>
<div class="content-area">
{{if $isFirstPage}}
<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 class="metric-value">{{$report.Metric.Name}}</div>
</div>
{{if .Metric.ClientID}}
{{if $report.Metric.ClientID}}
<div class="metric-row">
<div class="metric-label">Device ID:</div>
<div class="metric-value">{{.Metric.ClientID}}</div>
<div class="metric-value">{{$report.Metric.ClientID}}</div>
</div>
{{end}}
<div class="metric-row">
<div class="metric-label">Channel ID:</div>
<div class="metric-value">{{.Metric.ChannelID}}</div>
<div class="metric-value">{{$report.Metric.ChannelID}}</div>
</div>
</div>
</div>
<div class="record-count">
Total Records: {{len .Messages}}
Total Records: {{$totalMessages}}
</div>
{{else}}
<div class="metrics-section continuation">
<div class="metrics-title">Metrics (continued)</div>
</div>
{{end}}
<div class="table-container">
<div class="table-header-bar"></div>
@@ -398,15 +387,17 @@
</tr>
</thead>
<tbody>
{{range .Messages}}
{{range $msgIndex, $msg := $report.Messages}}
{{if and (ge $msgIndex $startRow) (lt $msgIndex $endRow)}}
<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>
<td class="col-time">{{formatTime $msg.Time}}</td>
<td class="col-value">{{formatValue $msg}}</td>
<td class="col-unit">{{$msg.Unit}}</td>
<td class="col-protocol">{{$msg.Protocol}}</td>
<td class="col-subtopic">{{$msg.Subtopic}}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
@@ -421,8 +412,8 @@
</div>
</div>
{{end}}
{{end}}
{{else}}
{{$globalPage = 1}}
<div class="page">
<div class="header">
<div class="header-top-bar"></div>
@@ -440,11 +431,11 @@
<div class="metrics-info">
<div class="metric-row">
<div class="metric-label">Name:</div>
<div class="metric-value">{{.Metric.Name}}</div>
<div class="metric-value">No Report</div>
</div>
<div class="metric-row">
<div class="metric-label">Channel ID:</div>
<div class="metric-value">{{.Metric.ChannelID}}</div>
<div class="metric-value">N/A</div>
</div>
</div>
</div>
@@ -478,7 +469,7 @@
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{.GeneratedTime}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
<div class="footer-page">Page 1 of 1</div>
</div>
</div>
</div>
+1 -1
View File
@@ -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()") {
+1 -1
View File
@@ -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
+48
View File
@@ -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)
+21 -3
View File
@@ -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,
+23 -3
View File
@@ -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
}
}