Skip to content

Commit

Permalink
Configurable time format and time related bugfixes (#198)
Browse files Browse the repository at this point in the history
* feat: Configurable time format. Time format used in the reports can be configured either from UI, env var or provisioned config. The format must conform to Golang's time layout. Any invalid time format will be ignored and replaced with default UnixDate layout. Starting from Grafana v11.3.0, the current dashboard's timezone is presented as a query parameter `timezone`. We use it to render panels and the plugin's own `timeZone` query parameter is a noop now. We left the parameter to support instances < 11.3.0

* fix: Support time string for from and to query params. Starting from Grafana v11.3.0, to and from query parameters are set as time strings whereas they were set to unix timestamps before.

* chore: Move config validation into Config struct receiver

---------

Signed-off-by: Mahendra Paipuri <mahendra.paipuri@gmail.com>
  • Loading branch information
mahendrapaipuri authored Dec 12, 2024
1 parent 087f292 commit 199e47a
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 45 deletions.
7 changes: 7 additions & 0 deletions pkg/plugin/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ func NewDashboardReporterApp(ctx context.Context, settings backend.AppInstanceSe
return nil, fmt.Errorf("error loading config: %w", err)
}

// Validate plugin config
if err := app.conf.Validate(); err != nil {
app.ctxLogger.Error("error config validation", "err", err)

return nil, fmt.Errorf("error config validation: %w", err)
}

app.ctxLogger.Info("starting plugin with initial config: " + app.conf.String())

// Make a new HTTP client
Expand Down
4 changes: 4 additions & 0 deletions pkg/plugin/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@ func (g GrafanaClient) getPanelPNGURL(p dashboard.Panel, dashUID string, t dashb
values.Add("from", t.From)
values.Add("to", t.To)

if g.conf.TimeZone != "" {
values.Add("timezone", g.conf.TimeZone)
}

// If using a grid layout we use 100px for width and 36px for height scaling.
// Grafana panels are fitted into 24 units width and height units are said to
// 30px in docs but 36px seems to be better.
Expand Down
78 changes: 76 additions & 2 deletions pkg/plugin/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package config

import (
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"

"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
Expand All @@ -13,6 +17,14 @@ import (

const SaToken = "saToken"

// Valid setting parameters.
var (
validThemes = []string{"light", "dark"}
validLayouts = []string{"simple", "grid"}
validOrientations = []string{"portrait", "landscape"}
validModes = []string{"default", "full"}
)

// Config contains plugin settings.
type Config struct {
AppURL string `env:"GF_REPORTER_PLUGIN_APP_URL, overwrite" json:"appUrl"`
Expand All @@ -22,6 +34,7 @@ type Config struct {
Layout string `env:"GF_REPORTER_PLUGIN_REPORT_LAYOUT, overwrite" json:"layout"`
DashboardMode string `env:"GF_REPORTER_PLUGIN_REPORT_DASHBOARD_MODE, overwrite" json:"dashboardMode"`
TimeZone string `env:"GF_REPORTER_PLUGIN_REPORT_TIMEZONE, overwrite" json:"timeZone"`
TimeFormat string `env:"GF_REPORTER_PLUGIN_REPORT_TIMEFORMAT, overwrite" json:"timeFormat"`
EncodedLogo string `env:"GF_REPORTER_PLUGIN_REPORT_LOGO, overwrite" json:"logo"`
HeaderTemplate string `env:"GF_REPORTER_PLUGIN_REPORT_HEADER_TEMPLATE, overwrite" json:"headerTemplate"`
FooterTemplate string `env:"GF_REPORTER_PLUGIN_REPORT_FOOTER_TEMPLATE, overwrite" json:"footerTemplate"`
Expand All @@ -32,13 +45,68 @@ type Config struct {
ExcludePanelIDs []string
IncludePanelDataIDs []string

// Time location
Location *time.Location

// HTTP Client
HTTPClientOptions httpclient.Options

// Secrets
Token string
}

// Validate checks current settings and sets them to defaults for invalid ones.
func (c *Config) Validate() error {
// Check theme
if !slices.Contains(validThemes, c.Theme) {
return fmt.Errorf("theme: %s must be one of [%s]", c.Theme, strings.Join(validThemes, ","))
}

// Check layout
if !slices.Contains(validLayouts, c.Layout) {
return fmt.Errorf("layout: %s must be one of [%s]", c.Layout, strings.Join(validLayouts, ","))
}

// Check Orientation
if !slices.Contains(validOrientations, c.Orientation) {
return fmt.Errorf("orientation: %s must be one of [%s]", c.Orientation, strings.Join(validOrientations, ","))
}

// Check Mode
if !slices.Contains(validModes, c.DashboardMode) {
return fmt.Errorf("dashboard mode: %s must be one of [%s]", c.DashboardMode, strings.Join(validModes, ","))
}

// Set time zone to current server time zone if empty
if loc, err := time.LoadLocation(c.TimeZone); err != nil || c.TimeZone == "" {
c.Location = time.Now().Local().Location()
c.TimeZone = c.Location.String()
} else {
c.Location = loc
c.TimeZone = loc.String()
}

// Set time format to time.UnixDate if the provided one is invalid
t := time.Now().Format(c.TimeFormat)
if parsedTime, err := time.Parse(c.TimeFormat, t); err != nil || parsedTime.Unix() <= 0 {
c.TimeFormat = time.UnixDate
}

// Verify RemoteChromeURL
// url.Parse almost allows all the URLs. Need to check Scheme and Host
if c.RemoteChromeURL != "" {
if u, err := url.Parse(c.RemoteChromeURL); err != nil {
return err
} else {
if u.Scheme == "" || u.Host == "" {
return errors.New("remote chrome url is invalid")
}
}
}

return nil
}

// String implements the stringer interface of Config.
func (c *Config) String() string {
var encodedLogo string
Expand Down Expand Up @@ -71,10 +139,10 @@ func (c *Config) String() string {

return fmt.Sprintf(
"Theme: %s; Orientation: %s; Layout: %s; Dashboard Mode: %s; "+
"Time Zone: %s; Encoded Logo: %s; "+
"Time Zone: %s; Time Format: %s; Encoded Logo: %s; "+
"Max Renderer Workers: %d; Max Browser Workers: %d; Remote Chrome Addr: %s; App URL: %s; "+
"TLS Skip verify: %v; Included Panel IDs: %s; Excluded Panel IDs: %s Included Data for Panel IDs: %s",
c.Theme, c.Orientation, c.Layout, c.DashboardMode, c.TimeZone,
c.Theme, c.Orientation, c.Layout, c.DashboardMode, c.TimeZone, c.TimeFormat,
encodedLogo, c.MaxRenderWorkers, c.MaxBrowserWorkers, c.RemoteChromeURL, appURL,
c.SkipTLSCheck, includedPanelIDs, excludedPanelIDs, includeDataPanelIDs,
)
Expand All @@ -90,6 +158,7 @@ func Load(ctx context.Context, settings backend.AppInstanceSettings) (Config, er
Layout: "simple",
DashboardMode: "default",
TimeZone: "",
TimeFormat: "",
EncodedLogo: "",
HeaderTemplate: "",
FooterTemplate: "",
Expand Down Expand Up @@ -125,6 +194,11 @@ func Load(ctx context.Context, settings backend.AppInstanceSettings) (Config, er
return Config{}, fmt.Errorf("error in reading config env vars: %w", err)
}

// Validate config
if err := config.Validate(); err != nil {
return Config{}, fmt.Errorf("error in config settings: %w", err)
}

// Get default HTTP client options
if config.HTTPClientOptions, err = settings.HTTPClientOptions(ctx); err != nil {
return Config{}, fmt.Errorf("error in http client options: %w", err)
Expand Down
16 changes: 12 additions & 4 deletions pkg/plugin/dashboard/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type TimeRange struct {
// To: "now-1d/d" -> end of yesterday
// When used as boundary, the same string will evaluate to a different time if used in 'From' or 'To'
// - absolute unix time: "142321234"
// - absolute time string: "2024-12-02T23:00:00.000Z" start from Grafana v11.3.0
//
// The required behaviour is clearly documented in the unit tests, time_test.go.
type now time.Time
Expand All @@ -34,6 +35,7 @@ const (
const (
relTimeRegExp = "^now([+-][0-9]+)([mhdwMy])$"
boundaryTimeRegExp = "^(.*?)/([dwMy])$"
layout = "2006-01-02T15:04:05.000Z"
)

// Convenience function to raise panic with custom message.
Expand Down Expand Up @@ -85,10 +87,16 @@ func roundTimeToBoundary(t time.Time, b boundary, boundaryUnit string) time.Time

// Parse time stamp to time.Unix() format.
func parseAbsTime(s string) time.Time {
// Check if time is in unix timestamp format
if timeInMs, err := strconv.ParseInt(s, 10, 64); err == nil {
return time.Unix(timeInMs/1000, 0)
}

// Check if time is in 2024-12-02T23:00:00.000Z format
if absTime, err := time.Parse(layout, s); err == nil && absTime.Unix() > 0 {
return absTime
}

panic(unrecognized(s))
}

Expand Down Expand Up @@ -120,17 +128,17 @@ func NewTimeRange(from, to string) TimeRange {
}

// Formats Grafana 'From' time spec into absolute printable time.
func (tr TimeRange) FromFormatted(loc *time.Location) string {
func (tr TimeRange) FromFormatted(loc *time.Location, layout string) string {
n := newNow()

return n.parseFrom(tr.From).In(loc).Format(time.UnixDate)
return n.parseFrom(tr.From).In(loc).Format(layout)
}

// Formats Grafana 'To' time spec into absolute printable time.
func (tr TimeRange) ToFormatted(loc *time.Location) string {
func (tr TimeRange) ToFormatted(loc *time.Location, layout string) string {
n := newNow()

return n.parseTo(tr.To).In(loc).Format(time.UnixDate)
return n.parseTo(tr.To).In(loc).Format(layout)
}

// Make current time custom struct.
Expand Down
15 changes: 3 additions & 12 deletions pkg/plugin/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,6 @@ type Options struct {
TimeRange dashboard.TimeRange
}

// Location of time zone.
func (o Options) location(timeZone string) *time.Location {
if location, err := time.LoadLocation(timeZone); err != nil {
return time.Now().Local().Location()
} else {
return location
}
}

// Data structures used inside HTML template.
type templateData struct {
Options
Expand All @@ -73,12 +64,12 @@ func (t templateData) IsGridLayout() bool {

// From returns from time string.
func (t templateData) From() string {
return t.TimeRange.FromFormatted(t.location(t.Conf.TimeZone))
return t.TimeRange.FromFormatted(t.Conf.Location, t.Conf.TimeFormat)
}

// To returns to time string.
func (t templateData) To() string {
return t.TimeRange.ToFormatted(t.location(t.Conf.TimeZone))
return t.TimeRange.ToFormatted(t.Conf.Location, t.Conf.TimeFormat)
}

// Logo returns encoded logo.
Expand Down Expand Up @@ -345,7 +336,7 @@ func (r *PDF) generateHTMLFile() error {
// Template data
data := templateData{
*r.options,
time.Now().Local().In(r.options.location(r.conf.TimeZone)).Format(time.RFC850),
time.Now().Local().In(r.conf.Location).Format(r.conf.TimeFormat),
r.grafanaDashboard,
r.conf,
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/plugin/report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/url"
"strings"
"testing"
"time"

"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/mahendrapaipuri/grafana-dashboard-reporter-app/pkg/plugin/config"
Expand Down Expand Up @@ -77,7 +78,7 @@ func TestReport(t *testing.T) {
worker.Renderer: worker.New(ctx, 2),
}

rep, err := New(logger, config.Config{}, nil, workerPools, gClient, &Options{
rep, err := New(logger, config.Config{TimeFormat: time.UnixDate, Location: time.Now().Location()}, nil, workerPools, gClient, &Options{
TimeRange: dashboard.TimeRange{From: "1453206447000", To: "1453213647000"},
DashUID: "testDash",
})
Expand Down
51 changes: 27 additions & 24 deletions pkg/plugin/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -287,48 +288,42 @@ func (app *App) handleReport(w http.ResponseWriter, req *http.Request) {

if req.URL.Query().Has("theme") {
conf.Theme = req.URL.Query().Get("theme")
if conf.Theme != "light" && conf.Theme != "dark" {
ctxLogger.Debug("invalid theme parameter: " + conf.Theme)
http.Error(w, "invalid theme parameter: "+conf.Theme, http.StatusBadRequest)

return
}
}

if req.URL.Query().Has("layout") {
conf.Layout = req.URL.Query().Get("layout")
if conf.Layout != "simple" && conf.Layout != "grid" {
ctxLogger.Debug("invalid layout parameter: " + conf.Layout)
http.Error(w, "invalid layout parameter: "+conf.Layout, http.StatusBadRequest)

return
}
}

if req.URL.Query().Has("orientation") {
conf.Orientation = req.URL.Query().Get("orientation")
if conf.Orientation != "portrait" && conf.Orientation != "landscape" {
ctxLogger.Debug("invalid orientation parameter: " + conf.Orientation)
http.Error(w, "invalid orientation parameter: "+conf.Orientation, http.StatusBadRequest)

return
}
}

if req.URL.Query().Has("dashboardMode") {
conf.DashboardMode = req.URL.Query().Get("dashboardMode")
if conf.DashboardMode != "default" && conf.DashboardMode != "full" {
ctxLogger.Warn("invalid dashboardMode parameter: " + conf.DashboardMode)
http.Error(w, "invalid dashboardMode parameter: "+conf.DashboardMode, http.StatusBadRequest)

return
}
}

if req.URL.Query().Has("timeZone") {
conf.TimeZone = req.URL.Query().Get("timeZone")
}

// Starting from Grafana v11.3.0, Grafana sets timezone query parameter.
// We should give priority to that over the plugin's config value.
// We will still support plugin's config parameter for backwards compatibility
if req.URL.Query().Has("timezone") {
timeZone := req.URL.Query().Get("timezone")
if !slices.Contains([]string{"browser", "default"}, timeZone) {
if timeZone == "utc" {
timeZone = "Etc/UTC"
}

conf.TimeZone = timeZone
}
}

if req.URL.Query().Has("timeFormat") {
conf.TimeFormat = req.URL.Query().Get("timeFormat")
}

if req.URL.Query().Has("includePanelID") {
conf.IncludePanelIDs = makePanelIDs(app.grafanaSemVer, req.URL.Query()["includePanelID"])
}
Expand All @@ -341,6 +336,14 @@ func (app *App) handleReport(w http.ResponseWriter, req *http.Request) {
conf.IncludePanelDataIDs = makePanelIDs(app.grafanaSemVer, req.URL.Query()["includePanelDataID"])
}

// Validate config again
if err := conf.Validate(); err != nil {
ctxLogger.Debug("invalid config: "+conf.String(), "err", err)
http.Error(w, "invalid config setting", http.StatusBadRequest)

return
}

ctxLogger.Info("generate report using config: " + conf.String())

// credential is header name value pair that will be used in API requests
Expand Down
Loading

0 comments on commit 199e47a

Please sign in to comment.