Skip to content

Commit

Permalink
feat: Support configurable time zone
Browse files Browse the repository at this point in the history
* Users can configure the time zone in report either by provisioning or query param

* Always create a new instance of config report handler

* Keep a singleton instance of current provisioned config

* Update docs

Signed-off-by: Mahendra Paipuri <mahendra.paipuri@gmail.com>
  • Loading branch information
mahendrapaipuri committed May 20, 2024
1 parent 7976ac0 commit 4b883e4
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 40 deletions.
1 change: 1 addition & 0 deletions .ci/config/tls/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ apps:
orientation: portrait
layout: simple
dashboardMode: default
timeZone: ''
logo: ''
maxRenderWorkers: 2
persistData: false
68 changes: 49 additions & 19 deletions pkg/plugin/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type Config struct {
Orientation string `json:"orientation"`
Layout string `json:"layout"`
DashboardMode string `json:"dashboardMode"`
TimeZone string `json:"timeZone"`
EncodedLogo string `json:"encodedLogo"`
MaxRenderWorkers int `json:"maxRenderWorkers"`
PersistData bool `json:"persistData"`
Expand Down Expand Up @@ -79,11 +80,12 @@ func (c *Config) String() string {
}
return fmt.Sprintf(
"Grafana App URL: %s; Skip TLS Check: %t; Grafana Data Path: %s; "+
"Orientation: %s; Layout: %s; Dashboard Mode: %s; Encoded Logo: %s; Max Renderer Workers: %d; "+
"Persist Data: %t; Included Panel IDs: %s; Excluded Panel IDs: %s",
c.AppURL, c.SkipTLSCheck, c.DataPath, c.Orientation, c.Layout,
c.DashboardMode, encodedLogo, c.MaxRenderWorkers, c.PersistData, includedPanelIDs,
excludedPanelIDs,
"Orientation: %s; Layout: %s; Dashboard Mode: %s; Time Zone: %s; Encoded Logo: %s; "+
"Max Renderer Workers: %d; "+
"Persist Data: %t; Included Panel IDs: %s; Excluded Panel IDs: %s",
c.AppURL, c.SkipTLSCheck, c.DataPath, c.Orientation, c.Layout,
c.DashboardMode, c.TimeZone, encodedLogo, c.MaxRenderWorkers, c.PersistData,
includedPanelIDs, excludedPanelIDs,
)
}

Expand All @@ -105,20 +107,13 @@ type App struct {
newReport func(logger log.Logger, grafanaClient GrafanaClient, options *ReportOptions) (Report, error)
}

// NewApp creates a new example *App instance.
func NewApp(ctx context.Context, settings backend.AppInstanceSettings) (instancemgmt.Instance, error) {
var app App
// Default config
var defaultConfig Config

// Get context logger for debugging
ctxLogger := log.DefaultLogger.FromContext(ctx)

// Use a httpadapter (provided by the SDK) for resource calls. This allows us
// to use a *http.ServeMux for resource calls, so we can map multiple routes
// to CallResource without having to implement extra logic.
mux := http.NewServeMux()
app.registerRoutes(mux)
app.CallResourceHandler = httpadapter.New(mux)
// Keep a state of current provisioned config
var currentAppConfig Config

func init() {
// Get Grafana data path based on path of current executable
pluginExe, err := os.Executable()
if err != nil {
Expand All @@ -129,14 +124,46 @@ func NewApp(ctx context.Context, settings backend.AppInstanceSettings) (instance
// Now we attempt to get install_dir directory which is Grafana data path
dataPath := filepath.Dir(filepath.Dir(filepath.Dir(pluginExe)))

// Update plugin settings defaults
var config = Config{
// Populate defaultConfig
defaultConfig = Config{
DataPath: dataPath,
Orientation: "portrait",
Layout: "simple",
DashboardMode: "default",
TimeZone: "",
EncodedLogo: "",
MaxRenderWorkers: 2,
}
}

// DefaultConfig returns an instance of default config
func DefaultConfig() Config {
return defaultConfig
}

// NewAppConfig returns an instance of current app's provisioned config
func NewAppConfig() Config {
return currentAppConfig
}

// NewApp creates a new example *App instance.
func NewApp(ctx context.Context, settings backend.AppInstanceSettings) (instancemgmt.Instance, error) {
var app App

// Get context logger for debugging
ctxLogger := log.DefaultLogger.FromContext(ctx)

// Use a httpadapter (provided by the SDK) for resource calls. This allows us
// to use a *http.ServeMux for resource calls, so we can map multiple routes
// to CallResource without having to implement extra logic.
mux := http.NewServeMux()
app.registerRoutes(mux)
app.CallResourceHandler = httpadapter.New(mux)

// Always start with a default config so that when the plugin is not provisioned
// with a config, we will still have "non-null" config to work with
var config = DefaultConfig()
// Update plugin settings defaults
if settings.JSONData != nil && string(settings.JSONData) != "null" {
if err := json.Unmarshal(settings.JSONData, &config); err == nil {
ctxLogger.Info("provisioned config", "config", config.String())
Expand Down Expand Up @@ -253,6 +280,9 @@ func NewApp(ctx context.Context, settings backend.AppInstanceSettings) (instance
config.ChromeOptions = append(config.ChromeOptions, chromedp.ExecPath(chromeExec))
}

// Set current App's config
currentAppConfig = config

// Make config
app.config = &config

Expand Down
6 changes: 4 additions & 2 deletions pkg/plugin/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,13 @@ func filterPanels(panels []Panel, config *Config) []Panel {
// Iterate over all panels and check if they should be included or not
var filteredPanels []Panel
for _, panel := range panels {
if len(config.IncludePanelIDs) > 0 && slices.Contains(config.IncludePanelIDs, panel.ID) && !slices.Contains(filteredPanels, panel) {
if len(config.IncludePanelIDs) > 0 && slices.Contains(config.IncludePanelIDs, panel.ID) &&
!slices.Contains(filteredPanels, panel) {
filteredPanels = append(filteredPanels, panel)
}

if len(config.ExcludePanelIDs) > 0 && !slices.Contains(config.ExcludePanelIDs, panel.ID) && !slices.Contains(filteredPanels, panel) {
if len(config.ExcludePanelIDs) > 0 && !slices.Contains(config.ExcludePanelIDs, panel.ID) &&
!slices.Contains(filteredPanels, panel) {
filteredPanels = append(filteredPanels, panel)
}
}
Expand Down
31 changes: 17 additions & 14 deletions pkg/plugin/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,28 @@ func (o ReportOptions) IsLandscapeOrientation() bool {

// Get from time string
func (o ReportOptions) From() string {
return o.timeRange.FromFormatted()
return o.timeRange.FromFormatted(o.location())
}

// Get to time string
func (o ReportOptions) To() string {
return o.timeRange.ToFormatted()
return o.timeRange.ToFormatted(o.location())
}

// Get logo
func (o ReportOptions) Logo() string {
return o.config.EncodedLogo
}

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

// report struct
type report struct {
logger log.Logger
Expand Down Expand Up @@ -266,11 +275,11 @@ func (r *report) generateHTMLFile(dash Dashboard) error {
return fmt.Errorf("error parsing report template: %v", err)
}

// Template data
data := templateData{dash, *r.options, time.Now().Local().In(r.options.location()).Format(time.RFC850)}

// Render the template for body of the report
if err = tmpl.ExecuteTemplate(
file,
"report.gohtml",
templateData{dash, *r.options, time.Now().Format(time.RFC850)}); err != nil {
if err = tmpl.ExecuteTemplate(file, "report.gohtml", data); err != nil {
return fmt.Errorf("error executing report template: %v", err)
}

Expand All @@ -281,10 +290,7 @@ func (r *report) generateHTMLFile(dash Dashboard) error {

// Render the template for header of the report
bufHeader := &bytes.Buffer{}
if err = tmpl.ExecuteTemplate(
bufHeader,
"header.gohtml",
templateData{dash, *r.options, time.Now().Format(time.RFC850)}); err != nil {
if err = tmpl.ExecuteTemplate(bufHeader, "header.gohtml", data); err != nil {
return fmt.Errorf("error executing header template: %v", err)
}
r.options.header = bufHeader.String()
Expand All @@ -296,10 +302,7 @@ func (r *report) generateHTMLFile(dash Dashboard) error {

// Render the template for footer of the report
bufFooter := &bytes.Buffer{}
if err = tmpl.ExecuteTemplate(
bufFooter,
"footer.gohtml",
templateData{dash, *r.options, time.Now().Format(time.RFC850)}); err != nil {
if err = tmpl.ExecuteTemplate(bufFooter, "footer.gohtml", data); err != nil {
return fmt.Errorf("error executing footer template: %v", err)
}
r.options.footer = bufFooter.String()
Expand Down
7 changes: 7 additions & 0 deletions pkg/plugin/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ func (a *App) handleReport(w http.ResponseWriter, req *http.Request) {
timeRange := NewTimeRange(req.URL.Query().Get("from"), req.URL.Query().Get("to"))
ctxLogger.Debug("time range", "range", timeRange, "user", currentUser, "dash_uid", dashboardUID)

// Always start with new instance of app config for each request
configInstance := NewAppConfig()
a.config = &configInstance

// Get custom settings if provided in Plugin settings
// Seems like when json.RawMessage is nil, it actually returns []byte("null"). So
// we need to check for both
Expand Down Expand Up @@ -124,6 +128,9 @@ func (a *App) handleReport(w http.ResponseWriter, req *http.Request) {
a.config.DashboardMode = queryDashboardMode[len(queryDashboardMode)-1]
}
}
if timeZone, ok := req.URL.Query()["timeZone"]; ok {
a.config.TimeZone = timeZone[len(timeZone)-1]
}

// Two special query parameters: includePanelID and excludePanelID
// The query parameters are self explanatory and based on the values set to them
Expand Down
8 changes: 4 additions & 4 deletions pkg/plugin/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,15 @@ func NewTimeRange(from, to string) TimeRange {
}

// Formats Grafana 'From' time spec into absolute printable time
func (tr TimeRange) FromFormatted() string {
func (tr TimeRange) FromFormatted(loc *time.Location) string {
n := newNow()
return n.parseFrom(tr.From).Format(time.UnixDate)
return n.parseFrom(tr.From).In(loc).Format(time.UnixDate)
}

// Formats Grafana 'To' time spec into absolute printable time
func (tr TimeRange) ToFormatted() string {
func (tr TimeRange) ToFormatted(loc *time.Location) string {
n := newNow()
return n.parseTo(tr.To).Format(time.UnixDate)
return n.parseTo(tr.To).In(loc).Format(time.UnixDate)
}

// Make current time custom struct
Expand Down
13 changes: 13 additions & 0 deletions provisioning/plugins/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ apps:
#
dashboardMode: default

# Time zone to use the report. This should be provided in IANA format.
# More details on IANA format can be obtained from https://www.iana.org/time-zones
# Eg America/New_York, Asia/Singapore, Australia/Melbourne, Europe/Berlin
#
# If empty or an invalid format is provided, the plugin defaults to using local
# location of the Grafana server.
#
# This setting can be overridden for a particular dashboard by using query parameter
# ?timeZone=America%2FNew_York during report generation process. Note that we
# need to escape URL characters
#
timeZone: ''

# Branding logo in the report.
#
# A base64 encoded of the logo can be set which will be included in the footer
Expand Down
9 changes: 9 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ All the configuration parameters can only be modified by `Admin` role.
Whereas in full mode, rows are un collapsed and all the panels are included in the
report

- `Time Zone`: The time zone that will be used in the report. It has to conform to the
[IANA format](https://www.iana.org/time-zones). By default, local Grafana server's
time zone will be used.

Although these parameters can only be changed by users with `Admin` role for whole instance
of Grafana, it is possible to override the global defaults for a particular report
by using query parameters. It is enough to add query parameters to dashboard report URL
Expand All @@ -179,6 +183,11 @@ to set these values.
- Query field for dashboard mode is `dashboardMode` and it takes either `default` or `full`
as value. Example is `<grafanaAppUrl>/api/plugins/mahendrapaipuri-dashboardreporter-app/resources/report?dashUid=<UID of dashboard>&dashboardMode=full`

- Query field for dashboard mode is `timeZone` and it takes a value in [IANA format](https://www.iana.org/time-zones)
as value. **Note** that it should be encoded to escape URL specific characters. For example
to use `America/New_York` query parameter should be
`<grafanaAppUrl>/api/plugins/mahendrapaipuri-dashboardreporter-app/resources/report?dashUid=<UID of dashboard>&timeZone=America%2FNew_York`

Besides there are two special query parameters available namely:

- `includePanelID`: This can be used to include only panels with IDs set in the query in
Expand Down
37 changes: 37 additions & 0 deletions src/components/AppConfig/AppConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type JsonData = {
orientation?: string;
layout?: string;
dashboardMode?: string;
timeZone?: string;
logo?: string;
maxRenderWorkers?: number;
persistData?: boolean;
Expand All @@ -44,6 +45,10 @@ type State = {
dashboardMode: string;
// If dashboardMode has changed
dashboardModeChanged: boolean;
// time zone in IANA format
timeZone: string;
// If timeZone has changed
timeZoneChanged: boolean;
// base64 encoded logo
logo: string;
// If logo has changed
Expand Down Expand Up @@ -76,6 +81,8 @@ export const AppConfig = ({ plugin }: Props) => {
layoutChanged: false,
dashboardMode: jsonData?.dashboardMode || "default",
dashboardModeChanged: false,
timeZone: jsonData?.timeZone || "",
timeZoneChanged: false,
logo: jsonData?.logo || "",
logoChanged: false,
maxRenderWorkers: jsonData?.maxRenderWorkers || 2,
Expand Down Expand Up @@ -129,6 +136,15 @@ export const AppConfig = ({ plugin }: Props) => {
});
};

const onChangetimeZone = (event: ChangeEvent<HTMLInputElement>) => {
setState({
...state,
timeZone: event.target.value,
timeZoneChanged: true,
});
};


const onChangeLogo = (event: ChangeEvent<HTMLInputElement>) => {
setState({
...state,
Expand Down Expand Up @@ -190,6 +206,7 @@ export const AppConfig = ({ plugin }: Props) => {
orientation: state.orientation,
layout: state.layout,
dashboardMode: state.dashboardMode,
timeZone: state.timeZone,
logo: state.logo,
persistData: state.persistData,
},
Expand Down Expand Up @@ -226,6 +243,7 @@ export const AppConfig = ({ plugin }: Props) => {
orientation: state.orientation,
layout: state.layout,
dashboardMode: state.dashboardMode,
timeZone: state.timeZone,
logo: state.logo,
persistData: state.persistData,
},
Expand Down Expand Up @@ -310,6 +328,23 @@ export const AppConfig = ({ plugin }: Props) => {
/>
</Field>

{/* Time zone */}
<Field
label="Time Zone"
description="Time Zone in IANA format. By default time zone of the server will be used."
data-testid={testIds.appConfig.tz}
className={s.marginTop}
>
<Input
type="string"
width={60}
id="tz"
label={`Time Zone`}
value={state.timeZone}
onChange={onChangetimeZone}
/>
</Field>

{/* Branding logo */}
<Field
label="Branding Logo"
Expand Down Expand Up @@ -373,6 +408,7 @@ export const AppConfig = ({ plugin }: Props) => {
orientation: state.orientation,
layout: state.layout,
dashboardMode: state.dashboardMode,
timeZone: state.timeZone,
logo: state.logo,
persistData: state.persistData,
},
Expand All @@ -389,6 +425,7 @@ export const AppConfig = ({ plugin }: Props) => {
!state.layoutChanged &&
!state.orientationChanged &&
!state.dashboardModeChanged &&
!state.timeZoneChanged &&
!state.logoChanged &&
!state.maxRenderWorkersChanged &&
!state.persistDataChanged &&
Expand Down
Loading

0 comments on commit 4b883e4

Please sign in to comment.