Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 97 additions & 15 deletions admin/server/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -561,14 +562,23 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
res.Watermark = "inherit"
res.Intervals.Duration = opts.IntervalDuration

if opts.ExportFormat == runtimev1.ExportFormat_EXPORT_FORMAT_PDF {
if opts.Canvas == "" {
return nil, fmt.Errorf("PDF export requires a canvas")
}
if opts.Resolver != "" || opts.QueryName != "" { // nolint:staticcheck // backwards compatibility
return nil, fmt.Errorf("PDF export does not support a query or data resolver")
}
}

if opts.Resolver != "" {
res.Data = map[string]any{
opts.Resolver: opts.ResolverProperties,
}
}
res.Query.Name = opts.QueryName // nolint:staticcheck // backwards compatibility
res.Query.ArgsJSON = opts.QueryArgsJson // nolint:staticcheck // backwards compatibility
res.Export.Format = opts.ExportFormat.String()
res.Export.Format = exportFormatYAML(opts.ExportFormat)
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)

Expand All @@ -593,6 +603,13 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
}
res.Annotations.Explore = opts.Explore
res.Annotations.Canvas = opts.Canvas
if opts.Canvas != "" {
filtersJSON, err := metricsViewFiltersJSON(opts.MetricsViewFilters)
if err != nil {
return nil, err
}
res.Annotations.MetricsViewFilters = filtersJSON
}
return yaml.Marshal(res)
}

Expand All @@ -606,19 +623,6 @@ func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, er
}
}

// Format export format as pretty string
var exportFormat string
switch opts.ExportFormat {
case runtimev1.ExportFormat_EXPORT_FORMAT_CSV:
exportFormat = "csv"
case runtimev1.ExportFormat_EXPORT_FORMAT_PARQUET:
exportFormat = "parquet"
case runtimev1.ExportFormat_EXPORT_FORMAT_XLSX:
exportFormat = "xlsx"
default:
exportFormat = opts.ExportFormat.String()
}

res := reportYAML{}
res.Type = "report"
res.DisplayName = opts.DisplayName
Expand All @@ -631,9 +635,18 @@ func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, er
opts.Resolver: opts.ResolverProperties,
}
}
if opts.ExportFormat == runtimev1.ExportFormat_EXPORT_FORMAT_PDF {
if opts.Canvas == "" {
return nil, fmt.Errorf("PDF export requires a canvas")
}
if opts.Resolver != "" || opts.QueryName != "" { // nolint:staticcheck // backwards compatibility
return nil, fmt.Errorf("PDF export does not support a query or data resolver")
}
}

res.Query.Name = opts.QueryName // nolint:staticcheck // backwards compatibility
res.Query.Args = args
res.Export.Format = exportFormat
res.Export.Format = exportFormatYAML(opts.ExportFormat)
Comment thread
nishantmonu51 marked this conversation as resolved.
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)
res.Notify.Email.Recipients = opts.EmailRecipients
Expand All @@ -648,9 +661,57 @@ func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, er
if !res.Annotations.WebOpenMode.Valid() {
return nil, fmt.Errorf("invalid web open mode %q", opts.WebOpenMode)
}
if opts.Explore != "" && opts.Canvas != "" {
return nil, fmt.Errorf("cannot set both explore and canvas")
}
res.Annotations.Explore = opts.Explore
res.Annotations.Canvas = opts.Canvas
if opts.Canvas != "" {
// Canvas reports need the captured canvas state and filters to render correctly.
res.Annotations.WebOpenState = opts.WebOpenState
filtersJSON, err := metricsViewFiltersJSON(opts.MetricsViewFilters)
if err != nil {
return nil, err
}
res.Annotations.MetricsViewFilters = filtersJSON
}
return yaml.Marshal(res)
}

// reportFilterMaxSize limits the total size of the serialized filter expressions stored in a report's annotations.
// Mirrors magicAuthTokenFilterMaxSize for magic auth token filters.
const reportFilterMaxSize = 1024

// metricsViewFiltersJSON serializes per-metrics-view filter expressions to a JSON object
// of metrics view name to filter expression in protojson format.
// It is stored in the "metrics_view_filters" report annotation and parsed by the report reconciler.
func metricsViewFiltersJSON(filters map[string]*runtimev1.Expression) (string, error) {
if len(filters) == 0 {
return "", nil
}
var filterSize int
res := make(map[string]json.RawMessage, len(filters))
for mv, expr := range filters {
if mv == "" {
return "", fmt.Errorf("empty metrics view name in metrics view filters")
}
data, err := protojson.Marshal(expr)
if err != nil {
return "", fmt.Errorf("failed to serialize filter for metrics view %q: %w", mv, err)
}
filterSize += len(data)
if filterSize > reportFilterMaxSize {
return "", fmt.Errorf("filter size exceeds limit of %d bytes", reportFilterMaxSize)
}
res[mv] = data
}
data, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("failed to serialize metrics view filters: %w", err)
}
return string(data), nil
}

func (s *Server) createMagicTokens(ctx context.Context, orgID, projectID, reportName, ownerID string, emails []string, resources []*adminv1.ResourceName) (map[string]string, error) {
var createdByUserID *string
if ownerID != "" {
Expand Down Expand Up @@ -874,6 +935,10 @@ type reportAnnotations struct {
WebOpenMode WebOpenMode `yaml:"web_open_mode,omitempty"`
Explore string `yaml:"explore,omitempty"`
Canvas string `yaml:"canvas,omitempty"`
// Per-metrics-view filters of the canvas at scheduling time, as a JSON object of
// metrics view name to filter expression in protojson format (canvas reports only).
// The report reconciler bakes these into the report's transitive security rules.
MetricsViewFilters string `yaml:"metrics_view_filters,omitempty"`
}

type WebOpenMode string
Expand Down Expand Up @@ -905,6 +970,7 @@ func parseReportAnnotations(annotations map[string]string) reportAnnotations {
res.WebOpenState = annotations["web_open_state"]
res.Explore = annotations["explore"]
res.Canvas = annotations["canvas"]
res.MetricsViewFilters = annotations["metrics_view_filters"]
switch annotations["web_open_mode"] {
case "recipient":
res.WebOpenMode = WebOpenModeRecipient
Expand All @@ -919,6 +985,22 @@ func parseReportAnnotations(annotations map[string]string) reportAnnotations {
return res
}

// exportFormatYAML formats an export format enum as the string used in report YAML (parsed by runtime/parser.parseExportFormat).
func exportFormatYAML(f runtimev1.ExportFormat) string {
switch f {
case runtimev1.ExportFormat_EXPORT_FORMAT_CSV:
return "csv"
case runtimev1.ExportFormat_EXPORT_FORMAT_XLSX:
return "xlsx"
case runtimev1.ExportFormat_EXPORT_FORMAT_PARQUET:
return "parquet"
case runtimev1.ExportFormat_EXPORT_FORMAT_PDF:
return "pdf"
default:
return f.String()
}
}

func virtualFilePathForManagedReport(name string) string {
return path.Join("reports", name+".yaml")
}
8 changes: 8 additions & 0 deletions proto/gen/rill/admin/v1/admin.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5424,6 +5424,7 @@ definitions:
- EXPORT_FORMAT_CSV
- EXPORT_FORMAT_XLSX
- EXPORT_FORMAT_PARQUET
- EXPORT_FORMAT_PDF
default: EXPORT_FORMAT_UNSPECIFIED
v1Expression:
type: object
Expand Down Expand Up @@ -6722,6 +6723,13 @@ definitions:
title: either canvas or explore should be set
canvas:
type: string
metricsViewFilters:
type: object
additionalProperties:
$ref: '#/definitions/v1Expression'
description: |-
Per-metrics-view filters of the canvas at scheduling time (canvas PDF reports only).
Baked into the report's security rules so magic-token recipients cannot query unfiltered data.
webOpenMode:
type: string
title: |-
Expand Down
Loading
Loading