mirror of
https://github.com/absmach/supermq.git
synced 2026-06-23 04:10:34 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user