mirror of https://github.com/grafana/grafana
schema: Generate Go and Typescript from Thema coremodels (#49193)
* Add go code generator for coremodels * Just generate the entire coremodel for now Maybe we'll need more flexibility as more coremodels are added, but for now this is fine. * Add note on type comment about stability, grodkit * Remove local replace directive for thema * Generate typescript from coremodel * Update pkg/coremodel/dashboard/addenda.go Co-authored-by: Ryan McKinley <ryantxu@gmail.com> * Update cuetsy to new release * Update thema to latest * Fix enum generation for FieldColorModeId * Put main generated object at the end of the file * Tweaks to generated Go output * Retweak back to var * Add generated coremodel test * Remove local replace statement again * Add Make target and call into cuetsy cmd from gen * Rename and comment linsrc for readability * Move key codegen bits into reusable package * Move body of cuetsifier into codegen pkg Also genericize the diffing output into reusable WriteDiffer. * Refactor coremodel generator to use WriteDiffer * Add gen-cue step to CI * Whip all the codegen automation into shape * Add simplistic coremodel canonicality controls * Remove erroneously committed test * Bump thema version * Remove dead code * Improve wording of non-canonicality comment Co-authored-by: Ryan McKinley <ryantxu@gmail.com>pull/49751/head
parent
a641949a05
commit
be06d37a20
@ -0,0 +1,222 @@ |
||||
// This file is autogenerated. DO NOT EDIT.
|
||||
//
|
||||
// To regenerate, run "make gen-cue" from repository root.
|
||||
//
|
||||
// Derived from the Thema lineage at pkg/coremodel/dashboard
|
||||
|
||||
|
||||
// This model is a WIP and not yet canonical. Consequently, its members are
|
||||
// not exported to exclude it from grafana-schema's public API surface.
|
||||
|
||||
interface AnnotationQuery { |
||||
builtIn: number; |
||||
datasource: {}; |
||||
enable: boolean; |
||||
hide?: boolean; |
||||
iconColor?: string; |
||||
name?: string; |
||||
rawQuery?: string; |
||||
showIn: number; |
||||
target?: {}; |
||||
type: string; |
||||
} |
||||
|
||||
const defaultAnnotationQuery: Partial<AnnotationQuery> = { |
||||
builtIn: 0, |
||||
enable: true, |
||||
hide: false, |
||||
showIn: 0, |
||||
type: 'dashboard', |
||||
}; |
||||
|
||||
interface VariableModel { |
||||
label?: string; |
||||
name: string; |
||||
type: VariableType; |
||||
} |
||||
|
||||
interface DashboardLink { |
||||
asDropdown: boolean; |
||||
icon?: string; |
||||
includeVars: boolean; |
||||
keepTime: boolean; |
||||
tags: string[]; |
||||
targetBlank: boolean; |
||||
title: string; |
||||
tooltip?: string; |
||||
type: DashboardLinkType; |
||||
url?: string; |
||||
} |
||||
|
||||
const defaultDashboardLink: Partial<DashboardLink> = { |
||||
asDropdown: false, |
||||
includeVars: false, |
||||
keepTime: false, |
||||
tags: [], |
||||
targetBlank: false, |
||||
}; |
||||
|
||||
type DashboardLinkType = 'link' | 'dashboards'; |
||||
|
||||
type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'; |
||||
|
||||
enum FieldColorModeId { |
||||
ContinuousGrYlRd = 'continuous-GrYlRd', |
||||
Fixed = 'fixed', |
||||
PaletteClassic = 'palette-classic', |
||||
PaletteSaturated = 'palette-saturated', |
||||
Thresholds = 'thresholds', |
||||
} |
||||
|
||||
type FieldColorSeriesByMode = 'min' | 'max' | 'last'; |
||||
|
||||
interface FieldColor { |
||||
fixedColor?: string; |
||||
mode: FieldColorModeId | string; |
||||
seriesBy?: FieldColorSeriesByMode; |
||||
} |
||||
|
||||
interface Threshold { |
||||
color: string; |
||||
state?: string; |
||||
value?: number; |
||||
} |
||||
|
||||
enum ThresholdsMode { |
||||
Absolute = 'absolute', |
||||
Percentage = 'percentage', |
||||
} |
||||
|
||||
interface ThresholdsConfig { |
||||
mode: ThresholdsMode; |
||||
steps: Threshold[]; |
||||
} |
||||
|
||||
const defaultThresholdsConfig: Partial<ThresholdsConfig> = { |
||||
steps: [], |
||||
}; |
||||
|
||||
interface Transformation { |
||||
id: string; |
||||
options: {}; |
||||
} |
||||
|
||||
enum DashboardCursorSync { |
||||
Crosshair = 1, |
||||
Off = 0, |
||||
Tooltip = 2, |
||||
} |
||||
|
||||
const defaultDashboardCursorSync: DashboardCursorSync = DashboardCursorSync.Off; |
||||
|
||||
interface Panel { |
||||
datasource?: {}; |
||||
description?: string; |
||||
fieldConfig: { |
||||
defaults: {}; |
||||
overrides: { |
||||
matcher: { |
||||
id: string; |
||||
}; |
||||
properties: { |
||||
id: string; |
||||
}[]; |
||||
}[]; |
||||
}; |
||||
gridPos?: { |
||||
h: number; |
||||
w: number; |
||||
x: number; |
||||
y: number; |
||||
}; |
||||
id?: number; |
||||
interval?: string; |
||||
links?: DashboardLink[]; |
||||
maxDataPoints?: number; |
||||
options: {}; |
||||
pluginVersion?: string; |
||||
repeat?: string; |
||||
repeatDirection: 'h' | 'v'; |
||||
tags?: string[]; |
||||
targets?: {}[]; |
||||
thresholds?: any[]; |
||||
timeFrom?: string; |
||||
timeRegions?: any[]; |
||||
timeShift?: string; |
||||
title?: string; |
||||
transformations: Transformation[]; |
||||
transparent: boolean; |
||||
type: string; |
||||
} |
||||
|
||||
const defaultPanel: Partial<Panel> = { |
||||
links: [], |
||||
repeatDirection: 'h', |
||||
tags: [], |
||||
targets: [], |
||||
thresholds: [], |
||||
timeRegions: [], |
||||
transformations: [], |
||||
transparent: false, |
||||
}; |
||||
|
||||
interface Dashboard { |
||||
annotations?: { |
||||
list: AnnotationQuery[]; |
||||
}; |
||||
description?: string; |
||||
editable: boolean; |
||||
fiscalYearStartMonth?: number; |
||||
gnetId?: string; |
||||
graphTooltip: DashboardCursorSync; |
||||
id?: number; |
||||
links?: DashboardLink[]; |
||||
liveNow?: boolean; |
||||
panels?: Panel | { |
||||
type: 'graph'; |
||||
} | { |
||||
type: 'heatmap'; |
||||
} | { |
||||
type: 'row'; |
||||
collapsed: boolean; |
||||
id: number; |
||||
panels: Panel | { |
||||
type: 'graph'; |
||||
} | { |
||||
type: 'heatmap'; |
||||
}[]; |
||||
}[]; |
||||
refresh?: string | false; |
||||
schemaVersion: number; |
||||
style: 'light' | 'dark'; |
||||
tags?: string[]; |
||||
templating?: { |
||||
list: VariableModel[]; |
||||
}; |
||||
time?: { |
||||
from: string; |
||||
to: string; |
||||
}; |
||||
timepicker?: { |
||||
collapse: boolean; |
||||
enable: boolean; |
||||
hidden: boolean; |
||||
refresh_intervals: string[]; |
||||
}; |
||||
timezone?: 'browser' | 'utc' | ''; |
||||
title?: string; |
||||
uid?: string; |
||||
version?: number; |
||||
weekStart?: string; |
||||
} |
||||
|
||||
const defaultDashboard: Partial<Dashboard> = { |
||||
editable: true, |
||||
graphTooltip: DashboardCursorSync.Off, |
||||
links: [], |
||||
panels: [], |
||||
schemaVersion: 36, |
||||
style: 'dark', |
||||
tags: [], |
||||
timezone: 'browser', |
||||
}; |
@ -1,89 +0,0 @@ |
||||
package schema |
||||
|
||||
AxisPlacement: "auto" | "top" | "right" | "bottom" | "left" | "hidden" @cuetsy(kind="enum") |
||||
VisibilityMode: "auto" | "never" | "always" @cuetsy(kind="enum") |
||||
GraphDrawStyle: "line" | "bars" | "points" @cuetsy(kind="enum") |
||||
LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cuetsy(kind="enum") |
||||
ScaleDistribution: "linear" | "log" | "ordinal" @cuetsy(kind="enum") |
||||
GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(kind="enum") |
||||
StackingMode: "none" | "normal" | "percent" @cuetsy(kind="enum") |
||||
GraphTransform: "constant" | "negative-Y" @cuetsy(kind="enum") |
||||
BarAlignment: -1 | 0 | 1 @cuetsy(kind="enum",memberNames="Before|Center|After") |
||||
ScaleOrientation: 0 | 1 @cuetsy(kind="enum",memberNames="Horizontal|Vertical") |
||||
ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(kind="enum",memberNames="Up|Right|Down|Left") |
||||
LineStyle: { |
||||
fill?: "solid" | "dash" | "dot" | "square" |
||||
dash?: [...number] |
||||
} @cuetsy(kind="interface") |
||||
LineConfig: { |
||||
lineColor?: string |
||||
lineWidth?: number |
||||
lineInterpolation?: LineInterpolation |
||||
lineStyle?: LineStyle |
||||
|
||||
// Indicate if null values should be treated as gaps or connected. |
||||
// When the value is a number, it represents the maximum delta in the |
||||
// X axis that should be considered connected. For timeseries, this is milliseconds |
||||
spanNulls?: bool | number |
||||
} @cuetsy(kind="interface") |
||||
BarConfig: { |
||||
barAlignment?: BarAlignment |
||||
barWidthFactor?: number |
||||
barMaxWidth?: number |
||||
} @cuetsy(kind="interface") |
||||
FillConfig: { |
||||
fillColor?: string |
||||
fillOpacity?: number |
||||
fillBelowTo?: string |
||||
} @cuetsy(kind="interface") |
||||
PointsConfig: { |
||||
showPoints?: VisibilityMode |
||||
pointSize?: number |
||||
pointColor?: string |
||||
pointSymbol?: string |
||||
} @cuetsy(kind="interface") |
||||
ScaleDistributionConfig: { |
||||
type: ScaleDistribution |
||||
log?: number |
||||
} @cuetsy(kind="interface") |
||||
AxisConfig: { |
||||
axisPlacement?: AxisPlacement |
||||
axisLabel?: string |
||||
axisWidth?: number |
||||
axisSoftMin?: number |
||||
axisSoftMax?: number |
||||
axisGridShow?: bool |
||||
scaleDistribution?: ScaleDistributionConfig |
||||
} @cuetsy(kind="interface") |
||||
HideSeriesConfig: { |
||||
tooltip: bool |
||||
legend: bool |
||||
viz: bool |
||||
} @cuetsy(kind="interface") |
||||
StackingConfig: { |
||||
mode?: StackingMode |
||||
group?: string |
||||
} @cuetsy(kind="interface") |
||||
StackableFieldConfig: { |
||||
stacking?: StackingConfig |
||||
} @cuetsy(kind="interface") |
||||
HideableFieldConfig: { |
||||
hideFrom?: HideSeriesConfig |
||||
} @cuetsy(kind="interface") |
||||
GraphTresholdsStyleMode: "off" | "line" | "area" | "line+area" | "series" @cuetsy(kind="enum",memberNames="Off|Line|Area|LineAndArea|Series") |
||||
GraphThresholdsStyleConfig: { |
||||
mode: GraphTresholdsStyleMode |
||||
} @cuetsy(kind="interface") |
||||
GraphFieldConfig: { |
||||
LineConfig |
||||
FillConfig |
||||
PointsConfig |
||||
AxisConfig |
||||
BarConfig |
||||
StackableFieldConfig |
||||
HideableFieldConfig |
||||
drawStyle?: GraphDrawStyle |
||||
gradientMode?: GraphGradientMode |
||||
thresholdsStyle?: GraphThresholdsStyleConfig |
||||
transform?: GraphTransform |
||||
} @cuetsy(kind="interface") |
@ -1,15 +0,0 @@ |
||||
package schema |
||||
|
||||
LegendPlacement: "bottom" | "right" @cuetsy(kind="type") |
||||
|
||||
LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum") |
||||
|
||||
VizLegendOptions: { |
||||
displayMode: LegendDisplayMode |
||||
placement: LegendPlacement |
||||
asTable?: bool |
||||
isVisible?: bool |
||||
sortBy?: string |
||||
sortDesc?: bool |
||||
calcs: [...string] |
||||
} @cuetsy(kind="interface") |
@ -1,15 +0,0 @@ |
||||
package schema |
||||
|
||||
// TODO -- should not be table specific! |
||||
FieldTextAlignment: "auto" | "left" | "right" | "center" @cuetsy(kind="type") |
||||
|
||||
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image") |
||||
|
||||
TableFieldOptions: { |
||||
width?: number |
||||
minWidth?: number |
||||
align: FieldTextAlignment | *"auto" |
||||
displayMode: TableCellDisplayMode | *"auto" |
||||
hidden?: bool // ?? default is missing or false ?? |
||||
filterable?: bool |
||||
} @cuetsy(kind="interface") |
@ -1,8 +0,0 @@ |
||||
package schema |
||||
|
||||
VizTextDisplayOptions: { |
||||
// Explicit title text size |
||||
titleSize?: number |
||||
// Explicit value text size |
||||
valueSize?: number |
||||
} @cuetsy(kind="interface") |
@ -1,9 +0,0 @@ |
||||
package schema |
||||
|
||||
TooltipDisplayMode: "single" | "multi" | "none" @cuetsy(kind="enum") |
||||
SortOrder: "asc" | "desc" | "none" @cuetsy(kind="enum") |
||||
|
||||
VizTooltipOptions: { |
||||
mode: TooltipDisplayMode |
||||
sort: SortOrder |
||||
} @cuetsy(kind="interface") |
@ -0,0 +1,399 @@ |
||||
package codegen |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"go/ast" |
||||
"go/format" |
||||
"go/parser" |
||||
"go/token" |
||||
"io/ioutil" |
||||
"os" |
||||
"path/filepath" |
||||
"regexp" |
||||
"strings" |
||||
"testing/fstest" |
||||
"text/template" |
||||
|
||||
"cuelang.org/go/pkg/encoding/yaml" |
||||
"github.com/deepmap/oapi-codegen/pkg/codegen" |
||||
"github.com/getkin/kin-openapi/openapi3" |
||||
"github.com/grafana/cuetsy" |
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
"github.com/grafana/thema" |
||||
"github.com/grafana/thema/encoding/openapi" |
||||
"golang.org/x/tools/imports" |
||||
) |
||||
|
||||
// ExtractedLineage contains the results of statically analyzing a Grafana
|
||||
// directory for a Thema lineage.
|
||||
type ExtractedLineage struct { |
||||
Lineage thema.Lineage |
||||
// Absolute path to the coremodel's lineage.cue file.
|
||||
LineagePath string |
||||
// Path to the coremodel's lineage.cue file relative to repo root.
|
||||
RelativePath string |
||||
// Indicates whether the coremodel is considered canonical or not. Generated
|
||||
// code from not-yet-canonical coremodels should include appropriate caveats in
|
||||
// documentation and possibly be hidden from external public API surface areas.
|
||||
IsCanonical bool |
||||
} |
||||
|
||||
// ExtractLineage loads a Grafana Thema lineage from the filesystem.
|
||||
//
|
||||
// The provided path must be the absolute path to the file containing the
|
||||
// lineage to be loaded.
|
||||
//
|
||||
// This loading approach is intended primarily for use with code generators, or
|
||||
// other use cases external to grafana-server backend. For code within
|
||||
// grafana-server, prefer lineage loaders provided in e.g. pkg/coremodel/*.
|
||||
func ExtractLineage(path string, lib thema.Library) (*ExtractedLineage, error) { |
||||
if !filepath.IsAbs(path) { |
||||
return nil, fmt.Errorf("must provide an absolute path, got %q", path) |
||||
} |
||||
|
||||
ec := &ExtractedLineage{ |
||||
LineagePath: path, |
||||
} |
||||
|
||||
var find func(path string) (string, error) |
||||
find = func(path string) (string, error) { |
||||
parent := filepath.Dir(path) |
||||
if parent == path { |
||||
return "", errors.New("grafana root directory could not be found") |
||||
} |
||||
fp := filepath.Join(path, "go.mod") |
||||
if _, err := os.Stat(fp); err == nil { |
||||
return path, nil |
||||
} |
||||
return find(parent) |
||||
} |
||||
groot, err := find(path) |
||||
if err != nil { |
||||
return ec, err |
||||
} |
||||
|
||||
f, err := os.Open(ec.LineagePath) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("could not open lineage file at %s: %w", path, err) |
||||
} |
||||
|
||||
byt, err := ioutil.ReadAll(f) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
fs := fstest.MapFS{ |
||||
"lineage.cue": &fstest.MapFile{ |
||||
Data: byt, |
||||
}, |
||||
} |
||||
|
||||
ec.RelativePath, err = filepath.Rel(groot, filepath.Dir(path)) |
||||
if err != nil { |
||||
// should be unreachable, since we rootclimbed to find groot above
|
||||
panic(err) |
||||
} |
||||
ec.Lineage, err = cuectx.LoadGrafanaInstancesWithThema(ec.RelativePath, fs, lib) |
||||
if err != nil { |
||||
return ec, err |
||||
} |
||||
ec.IsCanonical = isCanonical(ec.Lineage.Name()) |
||||
return ec, nil |
||||
} |
||||
|
||||
func isCanonical(name string) bool { |
||||
return canonicalCoremodels[name] |
||||
} |
||||
|
||||
// FIXME specificying coremodel canonicality DOES NOT belong here - it should be part of the coremodel declaration.
|
||||
var canonicalCoremodels = map[string]bool{ |
||||
"dashboard": false, |
||||
} |
||||
|
||||
// GenerateGoCoremodel generates a standard Go coremodel from a Thema lineage.
|
||||
//
|
||||
// The provided path must be a directory. Generated code files will be written
|
||||
// to that path. The final element of the path must match the Lineage.Name().
|
||||
func (ls *ExtractedLineage) GenerateGoCoremodel(path string) (WriteDiffer, error) { |
||||
lin, lib := ls.Lineage, ls.Lineage.Library() |
||||
_, name := filepath.Split(path) |
||||
if name != lin.Name() { |
||||
return nil, fmt.Errorf("lineage name %q must match final element of path, got %q", lin.Name(), path) |
||||
} |
||||
|
||||
sch := thema.SchemaP(lin, thema.LatestVersion(lin)) |
||||
f, err := openapi.GenerateSchema(sch, nil) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("thema openapi generation failed: %w", err) |
||||
} |
||||
|
||||
str, err := yaml.Marshal(lib.Context().BuildFile(f)) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("cue-yaml marshaling failed: %w", err) |
||||
} |
||||
|
||||
loader := openapi3.NewLoader() |
||||
oT, err := loader.LoadFromData([]byte(str)) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("loading generated openapi failed; %w", err) |
||||
} |
||||
|
||||
gostr, err := codegen.Generate(oT, lin.Name(), codegen.Options{ |
||||
GenerateTypes: true, |
||||
SkipPrune: true, |
||||
SkipFmt: true, |
||||
UserTemplates: map[string]string{ |
||||
"imports.tmpl": fmt.Sprintf(tmplImports, ls.RelativePath), |
||||
"typedef.tmpl": tmplTypedef, |
||||
}, |
||||
}) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("openapi generation failed: %w", err) |
||||
} |
||||
|
||||
vars := goPkg{ |
||||
Name: lin.Name(), |
||||
LineagePath: ls.RelativePath, |
||||
LatestSeqv: sch.Version()[0], |
||||
LatestSchv: sch.Version()[1], |
||||
} |
||||
var buuf bytes.Buffer |
||||
err = tmplAddenda.Execute(&buuf, vars) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
|
||||
fset := token.NewFileSet() |
||||
gf, err := parser.ParseFile(fset, "coremodel_gen.go", gostr+buuf.String(), parser.ParseComments) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("generated go file parsing failed: %w", err) |
||||
} |
||||
m := makeReplacer(lin.Name()) |
||||
ast.Walk(m, gf) |
||||
|
||||
var buf bytes.Buffer |
||||
err = format.Node(&buf, fset, gf) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("ast printing failed: %w", err) |
||||
} |
||||
|
||||
byt, err := imports.Process("coremodel_gen.go", buf.Bytes(), nil) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("goimports processing failed: %w", err) |
||||
} |
||||
|
||||
// Generate the assignability test. TODO do this in a framework test instead
|
||||
var buf3 bytes.Buffer |
||||
err = tmplAssignableTest.Execute(&buf3, vars) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed generating assignability test file: %w", err) |
||||
} |
||||
|
||||
wd := NewWriteDiffer() |
||||
wd[filepath.Join(path, "coremodel_gen.go")] = byt |
||||
wd[filepath.Join(path, "coremodel_gen_test.go")] = buf3.Bytes() |
||||
|
||||
return wd, nil |
||||
} |
||||
|
||||
type goPkg struct { |
||||
Name string |
||||
LineagePath string |
||||
LatestSeqv, LatestSchv uint |
||||
IsComposed bool |
||||
} |
||||
|
||||
func (ls *ExtractedLineage) GenerateTypescriptCoremodel(path string) (WriteDiffer, error) { |
||||
_, name := filepath.Split(path) |
||||
if name != ls.Lineage.Name() { |
||||
return nil, fmt.Errorf("lineage name %q must match final element of path, got %q", ls.Lineage.Name(), path) |
||||
} |
||||
|
||||
schv := thema.SchemaP(ls.Lineage, thema.LatestVersion(ls.Lineage)).UnwrapCUE() |
||||
|
||||
parts, err := cuetsy.GenerateAST(schv, cuetsy.Config{}) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("cuetsy parts gen failed: %w", err) |
||||
} |
||||
|
||||
top, err := cuetsy.GenerateSingleAST(string(makeReplacer(ls.Lineage.Name())), schv, cuetsy.TypeInterface) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("cuetsy top gen failed: %w", err) |
||||
} |
||||
|
||||
// TODO until cuetsy can toposort its outputs, put the top/parent type at the bottom of the file.
|
||||
parts.Nodes = append(parts.Nodes, top.T, top.D) |
||||
// parts.Nodes = append([]ts.Decl{top.T, top.D}, parts.Nodes...)
|
||||
|
||||
var strb strings.Builder |
||||
var str string |
||||
fpath := ls.Lineage.Name() + ".gen.ts" |
||||
strb.WriteString(fmt.Sprintf(genHeader, ls.RelativePath)) |
||||
|
||||
if !ls.IsCanonical { |
||||
fpath = fmt.Sprintf("%s_experimental.gen.ts", ls.Lineage.Name()) |
||||
strb.WriteString(` |
||||
// This model is a WIP and not yet canonical. Consequently, its members are
|
||||
// not exported to exclude it from grafana-schema's public API surface.
|
||||
|
||||
`) |
||||
strb.WriteString(fmt.Sprint(parts)) |
||||
// TODO replace this regexp with cuetsy config for whether members are exported
|
||||
re := regexp.MustCompile(`(?m)^export `) |
||||
str = re.ReplaceAllLiteralString(strb.String(), "") |
||||
} else { |
||||
strb.WriteString(fmt.Sprint(parts)) |
||||
str = strb.String() |
||||
} |
||||
|
||||
wd := NewWriteDiffer() |
||||
wd[filepath.Join(path, fpath)] = []byte(str) |
||||
return wd, nil |
||||
} |
||||
|
||||
type modelReplacer string |
||||
|
||||
func makeReplacer(name string) modelReplacer { |
||||
return modelReplacer(fmt.Sprintf("%s%s", string(strings.ToUpper(name)[0]), name[1:])) |
||||
} |
||||
|
||||
func (m modelReplacer) Visit(n ast.Node) ast.Visitor { |
||||
switch x := n.(type) { |
||||
case *ast.Ident: |
||||
x.Name = m.replacePrefix(x.Name) |
||||
} |
||||
return m |
||||
} |
||||
|
||||
func (m modelReplacer) replacePrefix(str string) string { |
||||
if len(str) >= len(m) && str[:len(m)] == string(m) { |
||||
return strings.Replace(str, string(m), "Model", 1) |
||||
} |
||||
return str |
||||
} |
||||
|
||||
var genHeader = `// This file is autogenerated. DO NOT EDIT.
|
||||
//
|
||||
// To regenerate, run "make gen-cue" from repository root.
|
||||
//
|
||||
// Derived from the Thema lineage at %s
|
||||
|
||||
` |
||||
|
||||
var tmplImports = genHeader + `package {{ .PackageName }} |
||||
|
||||
import ( |
||||
"embed" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"context" |
||||
"encoding/base64" |
||||
"encoding/json" |
||||
"encoding/xml" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"net/url" |
||||
"path" |
||||
"path/filepath" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/deepmap/oapi-codegen/pkg/runtime" |
||||
openapi_types "github.com/deepmap/oapi-codegen/pkg/types" |
||||
"github.com/getkin/kin-openapi/openapi3" |
||||
"github.com/grafana/thema" |
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
) |
||||
` |
||||
|
||||
var tmplAddenda = template.Must(template.New("addenda").Parse(` |
||||
//go:embed lineage.cue
|
||||
var cueFS embed.FS |
||||
|
||||
// codegen ensures that this is always the latest Thema schema version
|
||||
var currentVersion = thema.SV({{ .LatestSeqv }}, {{ .LatestSchv }}) |
||||
|
||||
// Lineage returns the Thema lineage representing a Grafana {{ .Name }}.
|
||||
//
|
||||
// The lineage is the canonical specification of the current {{ .Name }} schema,
|
||||
// all prior schema versions, and the mappings that allow migration between
|
||||
// schema versions.
|
||||
{{- if .IsComposed }}//
|
||||
// This is the base variant of the schema. It does not include any composed
|
||||
// plugin schemas.{{ end }}
|
||||
func Lineage(lib thema.Library, opts ...thema.BindOption) (thema.Lineage, error) { |
||||
return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, lib, opts...) |
||||
} |
||||
|
||||
var _ thema.LineageFactory = Lineage |
||||
|
||||
// Coremodel contains the foundational schema declaration for {{ .Name }}s.
|
||||
type Coremodel struct { |
||||
lin thema.Lineage |
||||
} |
||||
|
||||
// Lineage returns the canonical dashboard Lineage.
|
||||
func (c *Coremodel) Lineage() thema.Lineage { |
||||
return c.lin |
||||
} |
||||
|
||||
// CurrentSchema returns the current (latest) {{ .Name }} Thema schema.
|
||||
func (c *Coremodel) CurrentSchema() thema.Schema { |
||||
return thema.SchemaP(c.lin, currentVersion) |
||||
} |
||||
|
||||
// GoType returns a pointer to an empty Go struct that corresponds to
|
||||
// the current Thema schema.
|
||||
func (c *Coremodel) GoType() interface{} { |
||||
return &Model{} |
||||
} |
||||
|
||||
func ProvideCoremodel(lib thema.Library) (*Coremodel, error) { |
||||
lin, err := Lineage(lib) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &Coremodel{ |
||||
lin: lin, |
||||
}, nil |
||||
} |
||||
`)) |
||||
|
||||
var tmplAssignableTest = template.Must(template.New("addenda").Parse(fmt.Sprintf(genHeader, "{{ .LineagePath }}") + `package {{ .Name }} |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
"github.com/grafana/thema" |
||||
) |
||||
|
||||
func TestSchemaAssignability(t *testing.T) { |
||||
lin, err := Lineage(cuectx.ProvideThemaLibrary()) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
sch := thema.SchemaP(lin, currentVersion) |
||||
|
||||
err = thema.AssignableTo(sch, &Model{}) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
} |
||||
`)) |
||||
|
||||
var tmplTypedef = `{{range .Types}} |
||||
{{ with .Schema.Description }}{{ . }}{{ else }}// {{.TypeName}} defines model for {{.JsonName}}.{{ end }}
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type {{.TypeName}} {{if and (opts.AliasTypes) (.CanAlias)}}={{end}} {{.Schema.TypeDecl}} |
||||
{{end}} |
||||
` |
@ -0,0 +1,134 @@ |
||||
package codegen |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"os" |
||||
"path/filepath" |
||||
"sort" |
||||
|
||||
"github.com/google/go-cmp/cmp" |
||||
"github.com/hashicorp/go-multierror" |
||||
"golang.org/x/sync/errgroup" |
||||
) |
||||
|
||||
// WriteDiffer is a pseudo-filesystem that supports batch-writing its contents
|
||||
// to the real filesystem, or batch-comparing its contents to the real
|
||||
// filesystem. Its intended use is for idiomatic `go generate`-style code
|
||||
// generators, where it is expected that the results of codegen are committed to
|
||||
// version control.
|
||||
//
|
||||
// In such cases, the normal behavior of a generator is to write files to disk,
|
||||
// but in CI, that behavior should change to verify that what is already on disk
|
||||
// is identical to the results of code generation. This allows CI to ensure that
|
||||
// the results of code generation are always up to date. WriteDiffer supports
|
||||
// these related behaviors through its Write() and Verify() methods, respectively.
|
||||
//
|
||||
// Note that the statelessness of WriteDiffer means that, if a particular input
|
||||
// to the code generator goes away, it will not notice generated files left
|
||||
// behind if their inputs are removed.
|
||||
// TODO introduce a search/match system
|
||||
type WriteDiffer map[string][]byte |
||||
|
||||
func NewWriteDiffer() WriteDiffer { |
||||
return WriteDiffer(make(map[string][]byte)) |
||||
} |
||||
|
||||
type writeSlice []struct { |
||||
path string |
||||
contents []byte |
||||
} |
||||
|
||||
// Verify checks the contents of each file against the filesystem. It emits an error
|
||||
// if any of its contained files differ.
|
||||
func (wd WriteDiffer) Verify() error { |
||||
var result error |
||||
|
||||
for _, item := range wd.toSlice() { |
||||
if _, err := os.Stat(item.path); err != nil { |
||||
if errors.Is(err, os.ErrNotExist) { |
||||
result = multierror.Append(result, fmt.Errorf("%s: generated file should exist, but does not", item.path)) |
||||
} else { |
||||
result = multierror.Append(result, fmt.Errorf("%s: could not stat generated file: %w", item.path, err)) |
||||
} |
||||
continue |
||||
} |
||||
|
||||
f, err := os.Open(filepath.Clean(item.path)) |
||||
if err != nil { |
||||
result = multierror.Append(result, fmt.Errorf("%s: %w", item.path, err)) |
||||
continue |
||||
} |
||||
|
||||
ob, err := io.ReadAll(f) |
||||
if err != nil { |
||||
result = multierror.Append(result, fmt.Errorf("%s: %w", item.path, err)) |
||||
continue |
||||
} |
||||
dstr := cmp.Diff(string(ob), string(item.contents)) |
||||
if dstr != "" { |
||||
result = multierror.Append(result, fmt.Errorf("%s would have changed:\n\n%s", item.path, dstr)) |
||||
} |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
// Write writes all of the files to their indicated paths.
|
||||
func (wd WriteDiffer) Write() error { |
||||
g, _ := errgroup.WithContext(context.TODO()) |
||||
g.SetLimit(12) |
||||
|
||||
for _, item := range wd.toSlice() { |
||||
it := item |
||||
g.Go(func() error { |
||||
err := os.MkdirAll(filepath.Dir(it.path), os.ModePerm) |
||||
if err != nil { |
||||
return fmt.Errorf("%s: failed to ensure parent directory exists: %w", it.path, err) |
||||
} |
||||
|
||||
if err := os.WriteFile(it.path, it.contents, 0644); err != nil { |
||||
return fmt.Errorf("%s: error while writing file: %w", it.path, err) |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
return g.Wait() |
||||
} |
||||
|
||||
func (wd WriteDiffer) toSlice() writeSlice { |
||||
sl := make(writeSlice, 0, len(wd)) |
||||
type ws struct { |
||||
path string |
||||
contents []byte |
||||
} |
||||
|
||||
for k, v := range wd { |
||||
sl = append(sl, ws{ |
||||
path: k, |
||||
contents: v, |
||||
}) |
||||
} |
||||
|
||||
sort.Slice(sl, func(i, j int) bool { |
||||
return sl[i].path < sl[j].path |
||||
}) |
||||
|
||||
return sl |
||||
} |
||||
|
||||
// Merge combines all the entries from the provided WriteDiffer into the callee
|
||||
// WriteDiffer. Duplicate paths result in an error.
|
||||
func (wd WriteDiffer) Merge(wd2 WriteDiffer) error { |
||||
for k, v := range wd2 { |
||||
if _, has := wd[k]; has { |
||||
return fmt.Errorf("path %s already exists in write differ", k) |
||||
} |
||||
wd[k] = v |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,343 @@ |
||||
package codegen |
||||
|
||||
import ( |
||||
"bytes" |
||||
gerrors "errors" |
||||
"fmt" |
||||
"io" |
||||
"io/fs" |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
"testing/fstest" |
||||
"text/template" |
||||
|
||||
"cuelang.org/go/cue" |
||||
"cuelang.org/go/cue/ast" |
||||
"cuelang.org/go/cue/errors" |
||||
cload "cuelang.org/go/cue/load" |
||||
"cuelang.org/go/cue/parser" |
||||
"github.com/grafana/cuetsy" |
||||
"github.com/grafana/grafana/pkg/schema/load" |
||||
) |
||||
|
||||
// The only import statement we currently allow in any models.cue file
|
||||
const allowedImport = "github.com/grafana/grafana/packages/grafana-schema/src/schema" |
||||
|
||||
var importMap = map[string]string{ |
||||
allowedImport: "@grafana/schema", |
||||
} |
||||
|
||||
// Hard-coded list of paths to skip. Remove a particular file as we're ready
|
||||
// to rely on the TypeScript auto-generated by cuetsy for that particular file.
|
||||
var skipPaths = []string{ |
||||
"public/app/plugins/panel/barchart/models.cue", |
||||
"public/app/plugins/panel/canvas/models.cue", |
||||
"public/app/plugins/panel/histogram/models.cue", |
||||
"public/app/plugins/panel/heatmap-new/models.cue", |
||||
"public/app/plugins/panel/candlestick/models.cue", |
||||
"public/app/plugins/panel/state-timeline/models.cue", |
||||
"public/app/plugins/panel/status-history/models.cue", |
||||
"public/app/plugins/panel/table/models.cue", |
||||
"public/app/plugins/panel/timeseries/models.cue", |
||||
} |
||||
|
||||
const prefix = "/" |
||||
|
||||
var paths = load.GetDefaultLoadPaths() |
||||
|
||||
// CuetsifyPlugins runs cuetsy against plugins' models.cue files.
|
||||
func CuetsifyPlugins(ctx *cue.Context, root string) (WriteDiffer, error) { |
||||
// TODO this whole func has a lot of old, crufty behavior from the scuemata era; needs TLC
|
||||
var fspaths load.BaseLoadPaths |
||||
var err error |
||||
|
||||
fspaths.BaseCueFS, err = populateMapFSFromRoot(paths.BaseCueFS, root, "") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
fspaths.DistPluginCueFS, err = populateMapFSFromRoot(paths.DistPluginCueFS, root, "") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
overlay, err := defaultOverlay(fspaths) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Prep the cue load config
|
||||
clcfg := &cload.Config{ |
||||
Overlay: overlay, |
||||
// FIXME these module paths won't work for things not under our cue.mod - AKA third-party plugins
|
||||
ModuleRoot: prefix, |
||||
Module: "github.com/grafana/grafana", |
||||
} |
||||
|
||||
// FIXME hardcoding paths to exclude is not the way to handle this
|
||||
excl := map[string]bool{ |
||||
"cue.mod": true, |
||||
"cue/scuemata": true, |
||||
"packages/grafana-schema/src/scuemata/dashboard": true, |
||||
"packages/grafana-schema/src/scuemata/dashboard/dist": true, |
||||
} |
||||
|
||||
exclude := func(path string) bool { |
||||
dir := filepath.Dir(path) |
||||
if excl[dir] { |
||||
return true |
||||
} |
||||
for _, p := range skipPaths { |
||||
if path == p { |
||||
return true |
||||
} |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
outfiles := NewWriteDiffer() |
||||
|
||||
cuetsify := func(in fs.FS) error { |
||||
seen := make(map[string]bool) |
||||
return fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error { |
||||
if err != nil { |
||||
return err |
||||
} |
||||
dir := filepath.Dir(path) |
||||
|
||||
if d.IsDir() || filepath.Ext(d.Name()) != ".cue" || seen[dir] || exclude(path) { |
||||
return nil |
||||
} |
||||
seen[dir] = true |
||||
clcfg.Dir = filepath.Join(root, dir) |
||||
// FIXME Horrible hack to figure out the identifier used for
|
||||
// imported packages - intercept the parser called by the loader to
|
||||
// look at the ast.Files on their way in to building.
|
||||
// Much better if we could work backwards from the cue.Value,
|
||||
// maybe even directly in cuetsy itself, and figure out when a
|
||||
// referenced object is "out of bounds".
|
||||
// var imports sync.Map
|
||||
var imports []*ast.ImportSpec |
||||
clcfg.ParseFile = func(name string, src interface{}) (*ast.File, error) { |
||||
f, err := parser.ParseFile(name, src, parser.ParseComments) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
imports = append(imports, f.Imports...) |
||||
return f, nil |
||||
} |
||||
if strings.Contains(path, "public/app/plugins") { |
||||
clcfg.Package = "grafanaschema" |
||||
} else { |
||||
clcfg.Package = "" |
||||
} |
||||
|
||||
// FIXME loading in this way causes all files in a dir to be loaded
|
||||
// as a single cue.Instance or cue.Value, which makes it quite
|
||||
// difficult to map them _back_ onto the original file and generate
|
||||
// discrete .gen.ts files for each .cue input. However, going one
|
||||
// .cue file at a time and passing it as the first arg to
|
||||
// load.Instances() means that the other files are ignored
|
||||
// completely, causing references between these files to be
|
||||
// unresolved, and thus encounter a different kind of error.
|
||||
insts := cload.Instances(nil, clcfg) |
||||
if len(insts) > 1 { |
||||
panic("extra instances") |
||||
} |
||||
bi := insts[0] |
||||
|
||||
v := ctx.BuildInstance(bi) |
||||
if v.Err() != nil { |
||||
return v.Err() |
||||
} |
||||
|
||||
var b []byte |
||||
f := &tsFile{} |
||||
seen := make(map[string]bool) |
||||
// FIXME explicitly mapping path patterns to conversion patterns
|
||||
// is exactly what we want to avoid
|
||||
switch { |
||||
// panel plugin models.cue files
|
||||
case strings.Contains(path, "public/app/plugins"): |
||||
for _, im := range imports { |
||||
ip := strings.Trim(im.Path.Value, "\"") |
||||
if ip != allowedImport { |
||||
// TODO make a specific error type for this
|
||||
return errors.Newf(im.Pos(), "import %q not allowed, panel plugins may only import from %q", ip, allowedImport) |
||||
} |
||||
// TODO this approach will silently swallow the unfixable
|
||||
// error case where multiple files in the same dir import
|
||||
// the same package to a different ident
|
||||
if !seen[ip] { |
||||
seen[ip] = true |
||||
f.Imports = append(f.Imports, convertImport(im)) |
||||
} |
||||
} |
||||
|
||||
// Extract the latest schema and its version number. (All of this goes away with Thema, whew)
|
||||
f.V = &tsModver{} |
||||
lins := v.LookupPath(cue.ParsePath("Panel.lineages")) |
||||
f.V.Lin, _ = lins.Len().Int64() |
||||
f.V.Lin = f.V.Lin - 1 |
||||
schs := lins.LookupPath(cue.MakePath(cue.Index(int(f.V.Lin)))) |
||||
f.V.Sch, _ = schs.Len().Int64() |
||||
f.V.Sch = f.V.Sch - 1 |
||||
latest := schs.LookupPath(cue.MakePath(cue.Index(int(f.V.Sch)))) |
||||
|
||||
b, err = cuetsy.Generate(latest, cuetsy.Config{}) |
||||
default: |
||||
b, err = cuetsy.Generate(v, cuetsy.Config{}) |
||||
} |
||||
|
||||
if err != nil { |
||||
return err |
||||
} |
||||
f.Body = string(b) |
||||
|
||||
var buf bytes.Buffer |
||||
err = tsTemplate.Execute(&buf, f) |
||||
outfiles[filepath.Join(root, strings.Replace(path, ".cue", ".gen.ts", -1))] = buf.Bytes() |
||||
return err |
||||
}) |
||||
} |
||||
|
||||
err = cuetsify(fspaths.BaseCueFS) |
||||
if err != nil { |
||||
return nil, gerrors.New(errors.Details(err, nil)) |
||||
} |
||||
err = cuetsify(fspaths.DistPluginCueFS) |
||||
if err != nil { |
||||
return nil, gerrors.New(errors.Details(err, nil)) |
||||
} |
||||
|
||||
return outfiles, nil |
||||
} |
||||
|
||||
func convertImport(im *ast.ImportSpec) *tsImport { |
||||
tsim := &tsImport{ |
||||
Pkg: importMap[allowedImport], |
||||
} |
||||
if im.Name != nil && im.Name.String() != "" { |
||||
tsim.Ident = im.Name.String() |
||||
} else { |
||||
sl := strings.Split(im.Path.Value, "/") |
||||
final := sl[len(sl)-1] |
||||
if idx := strings.Index(final, ":"); idx != -1 { |
||||
tsim.Pkg = final[idx:] |
||||
} else { |
||||
tsim.Pkg = final |
||||
} |
||||
} |
||||
return tsim |
||||
} |
||||
|
||||
func defaultOverlay(p load.BaseLoadPaths) (map[string]cload.Source, error) { |
||||
overlay := make(map[string]cload.Source) |
||||
|
||||
if err := toOverlay(prefix, p.BaseCueFS, overlay); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := toOverlay(prefix, p.DistPluginCueFS, overlay); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return overlay, nil |
||||
} |
||||
|
||||
func toOverlay(prefix string, vfs fs.FS, overlay map[string]cload.Source) error { |
||||
if !filepath.IsAbs(prefix) { |
||||
return fmt.Errorf("must provide absolute path prefix when generating cue overlay, got %q", prefix) |
||||
} |
||||
err := fs.WalkDir(vfs, ".", func(path string, d fs.DirEntry, err error) error { |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if d.IsDir() { |
||||
return nil |
||||
} |
||||
|
||||
f, err := vfs.Open(path) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer func(f fs.File) { |
||||
err := f.Close() |
||||
if err != nil { |
||||
return |
||||
} |
||||
}(f) |
||||
|
||||
b, err := io.ReadAll(f) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
overlay[filepath.Join(prefix, path)] = cload.FromBytes(b) |
||||
return nil |
||||
}) |
||||
|
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Helper function that populates an fs.FS by walking over a virtual filesystem,
|
||||
// and reading files from disk corresponding to each file encountered.
|
||||
func populateMapFSFromRoot(in fs.FS, root, join string) (fs.FS, error) { |
||||
out := make(fstest.MapFS) |
||||
err := fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error { |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if d.IsDir() { |
||||
return nil |
||||
} |
||||
// Ignore gosec warning G304. The input set here is necessarily
|
||||
// constrained to files specified in embed.go
|
||||
// nolint:gosec
|
||||
b, err := os.Open(filepath.Join(root, join, path)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
byt, err := io.ReadAll(b) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
out[path] = &fstest.MapFile{Data: byt} |
||||
return nil |
||||
}) |
||||
return out, err |
||||
} |
||||
|
||||
type tsFile struct { |
||||
V *tsModver |
||||
Imports []*tsImport |
||||
Body string |
||||
} |
||||
|
||||
type tsModver struct { |
||||
Lin, Sch int64 |
||||
} |
||||
|
||||
type tsImport struct { |
||||
Ident string |
||||
Pkg string |
||||
} |
||||
|
||||
var tsTemplate = template.Must(template.New("cuetsygen").Parse(`//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// This file is autogenerated. DO NOT EDIT.
|
||||
//
|
||||
// To regenerate, run "make gen-cue" from the repository root.
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
{{range .Imports}} |
||||
import * as {{.Ident}} from '{{.Pkg}}';{{end}} |
||||
{{if .V}} |
||||
export const modelVersion = Object.freeze([{{ .V.Lin }}, {{ .V.Sch }}]); |
||||
{{end}} |
||||
{{.Body}}`)) |
@ -0,0 +1,10 @@ |
||||
package dashboard |
||||
|
||||
// HandoffSchemaVersion is the minimum schemaVersion for dashboards at which the
|
||||
// Thema-based dashboard schema is possibly valid
|
||||
//
|
||||
// schemaVersion is the original version numbering system for dashboards. If a
|
||||
// dashboard is below this schemaVersion, it is necessary for the frontend
|
||||
// typescript dashboard migration logic to first run and get it past this
|
||||
// number, after which Thema can take over.
|
||||
const HandoffSchemaVersion = 36 |
@ -1,40 +0,0 @@ |
||||
package dashboard |
||||
|
||||
import ( |
||||
"github.com/grafana/thema" |
||||
) |
||||
|
||||
// Coremodel contains the foundational schema declaration for dashboards.
|
||||
type Coremodel struct { |
||||
lin thema.Lineage |
||||
} |
||||
|
||||
// Lineage returns the canonical dashboard Lineage.
|
||||
func (c *Coremodel) Lineage() thema.Lineage { |
||||
return c.lin |
||||
} |
||||
|
||||
func (c *Coremodel) CurrentSchema() thema.Schema { |
||||
sch, err := c.lin.Schema(currentVersion) |
||||
if err != nil { |
||||
// Only reachable if our own schema currentVersion does not exist, which
|
||||
// can really only happen transitionally during development
|
||||
panic(err) |
||||
} |
||||
return sch |
||||
} |
||||
|
||||
func (c *Coremodel) GoType() interface{} { |
||||
return &model{} |
||||
} |
||||
|
||||
func ProvideCoremodel(lib thema.Library) (*Coremodel, error) { |
||||
lin, err := Lineage(lib) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &Coremodel{ |
||||
lin: lin, |
||||
}, nil |
||||
} |
@ -0,0 +1,751 @@ |
||||
// This file is autogenerated. DO NOT EDIT.
|
||||
//
|
||||
// To regenerate, run "make gen-cue" from repository root.
|
||||
//
|
||||
// Derived from the Thema lineage at pkg/coremodel/dashboard
|
||||
|
||||
package dashboard |
||||
|
||||
import ( |
||||
"embed" |
||||
"path/filepath" |
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
"github.com/grafana/thema" |
||||
) |
||||
|
||||
// Defines values for DashboardGraphTooltip.
|
||||
const ( |
||||
ModelGraphTooltipN0 ModelGraphTooltip = 0 |
||||
|
||||
ModelGraphTooltipN1 ModelGraphTooltip = 1 |
||||
|
||||
ModelGraphTooltipN2 ModelGraphTooltip = 2 |
||||
) |
||||
|
||||
// Defines values for DashboardStyle.
|
||||
const ( |
||||
ModelStyleDark ModelStyle = "dark" |
||||
|
||||
ModelStyleLight ModelStyle = "light" |
||||
) |
||||
|
||||
// Defines values for DashboardTimezone.
|
||||
const ( |
||||
ModelTimezoneBrowser ModelTimezone = "browser" |
||||
|
||||
ModelTimezoneEmpty ModelTimezone = "" |
||||
|
||||
ModelTimezoneUtc ModelTimezone = "utc" |
||||
) |
||||
|
||||
// Defines values for DashboardDashboardCursorSync.
|
||||
const ( |
||||
ModelDashboardCursorSyncN0 ModelDashboardCursorSync = 0 |
||||
|
||||
ModelDashboardCursorSyncN1 ModelDashboardCursorSync = 1 |
||||
|
||||
ModelDashboardCursorSyncN2 ModelDashboardCursorSync = 2 |
||||
) |
||||
|
||||
// Defines values for DashboardDashboardLinkType.
|
||||
const ( |
||||
ModelDashboardLinkTypeDashboards ModelDashboardLinkType = "dashboards" |
||||
|
||||
ModelDashboardLinkTypeLink ModelDashboardLinkType = "link" |
||||
) |
||||
|
||||
// Defines values for DashboardFieldColorModeId.
|
||||
const ( |
||||
ModelFieldColorModeIdContinuousGrYlRd ModelFieldColorModeId = "continuous-GrYlRd" |
||||
|
||||
ModelFieldColorModeIdFixed ModelFieldColorModeId = "fixed" |
||||
|
||||
ModelFieldColorModeIdPaletteClassic ModelFieldColorModeId = "palette-classic" |
||||
|
||||
ModelFieldColorModeIdPaletteSaturated ModelFieldColorModeId = "palette-saturated" |
||||
|
||||
ModelFieldColorModeIdThresholds ModelFieldColorModeId = "thresholds" |
||||
) |
||||
|
||||
// Defines values for DashboardFieldColorSeriesByMode.
|
||||
const ( |
||||
ModelFieldColorSeriesByModeLast ModelFieldColorSeriesByMode = "last" |
||||
|
||||
ModelFieldColorSeriesByModeMax ModelFieldColorSeriesByMode = "max" |
||||
|
||||
ModelFieldColorSeriesByModeMin ModelFieldColorSeriesByMode = "min" |
||||
) |
||||
|
||||
// Defines values for DashboardGraphPanelType.
|
||||
const ( |
||||
ModelGraphPanelTypeGraph ModelGraphPanelType = "graph" |
||||
) |
||||
|
||||
// Defines values for DashboardHeatmapPanelType.
|
||||
const ( |
||||
ModelHeatmapPanelTypeHeatmap ModelHeatmapPanelType = "heatmap" |
||||
) |
||||
|
||||
// Defines values for DashboardPanelRepeatDirection.
|
||||
const ( |
||||
ModelPanelRepeatDirectionH ModelPanelRepeatDirection = "h" |
||||
|
||||
ModelPanelRepeatDirectionV ModelPanelRepeatDirection = "v" |
||||
) |
||||
|
||||
// Defines values for DashboardRowPanelType.
|
||||
const ( |
||||
ModelRowPanelTypeRow ModelRowPanelType = "row" |
||||
) |
||||
|
||||
// Defines values for DashboardThresholdsConfigMode.
|
||||
const ( |
||||
ModelThresholdsConfigModeAbsolute ModelThresholdsConfigMode = "absolute" |
||||
|
||||
ModelThresholdsConfigModePercentage ModelThresholdsConfigMode = "percentage" |
||||
) |
||||
|
||||
// Defines values for DashboardThresholdsMode.
|
||||
const ( |
||||
ModelThresholdsModeAbsolute ModelThresholdsMode = "absolute" |
||||
|
||||
ModelThresholdsModePercentage ModelThresholdsMode = "percentage" |
||||
) |
||||
|
||||
// Defines values for DashboardVariableModelType.
|
||||
const ( |
||||
ModelVariableModelTypeAdhoc ModelVariableModelType = "adhoc" |
||||
|
||||
ModelVariableModelTypeConstant ModelVariableModelType = "constant" |
||||
|
||||
ModelVariableModelTypeCustom ModelVariableModelType = "custom" |
||||
|
||||
ModelVariableModelTypeDatasource ModelVariableModelType = "datasource" |
||||
|
||||
ModelVariableModelTypeInterval ModelVariableModelType = "interval" |
||||
|
||||
ModelVariableModelTypeQuery ModelVariableModelType = "query" |
||||
|
||||
ModelVariableModelTypeSystem ModelVariableModelType = "system" |
||||
|
||||
ModelVariableModelTypeTextbox ModelVariableModelType = "textbox" |
||||
) |
||||
|
||||
// Defines values for DashboardVariableType.
|
||||
const ( |
||||
ModelVariableTypeAdhoc ModelVariableType = "adhoc" |
||||
|
||||
ModelVariableTypeConstant ModelVariableType = "constant" |
||||
|
||||
ModelVariableTypeCustom ModelVariableType = "custom" |
||||
|
||||
ModelVariableTypeDatasource ModelVariableType = "datasource" |
||||
|
||||
ModelVariableTypeInterval ModelVariableType = "interval" |
||||
|
||||
ModelVariableTypeQuery ModelVariableType = "query" |
||||
|
||||
ModelVariableTypeSystem ModelVariableType = "system" |
||||
|
||||
ModelVariableTypeTextbox ModelVariableType = "textbox" |
||||
) |
||||
|
||||
// Dashboard defines model for dashboard.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type Model struct { |
||||
Annotations *struct { |
||||
List []ModelAnnotationQuery `json:"list"` |
||||
} `json:"annotations,omitempty"` |
||||
|
||||
// Description of dashboard.
|
||||
Description *string `json:"description,omitempty"` |
||||
|
||||
// Whether a dashboard is editable or not.
|
||||
Editable bool `json:"editable"` |
||||
|
||||
// TODO docs
|
||||
FiscalYearStartMonth *int `json:"fiscalYearStartMonth,omitempty"` |
||||
GnetId *string `json:"gnetId,omitempty"` |
||||
GraphTooltip ModelGraphTooltip `json:"graphTooltip"` |
||||
|
||||
// Unique numeric identifier for the dashboard.
|
||||
// TODO must isolate or remove identifiers local to a Grafana instance...?
|
||||
Id *int64 `json:"id,omitempty"` |
||||
|
||||
// TODO docs
|
||||
Links *[]ModelDashboardLink `json:"links,omitempty"` |
||||
|
||||
// TODO docs
|
||||
LiveNow *bool `json:"liveNow,omitempty"` |
||||
Panels *[]interface{} `json:"panels,omitempty"` |
||||
|
||||
// TODO docs
|
||||
Refresh *interface{} `json:"refresh,omitempty"` |
||||
|
||||
// Version of the JSON schema, incremented each time a Grafana update brings
|
||||
// changes to said schema.
|
||||
// TODO this is the existing schema numbering system. It will be replaced by Thema's themaVersion
|
||||
SchemaVersion int `json:"schemaVersion"` |
||||
|
||||
// Theme of dashboard.
|
||||
Style ModelStyle `json:"style"` |
||||
|
||||
// Tags associated with dashboard.
|
||||
Tags *[]string `json:"tags,omitempty"` |
||||
Templating *struct { |
||||
List []ModelVariableModel `json:"list"` |
||||
} `json:"templating,omitempty"` |
||||
|
||||
// Time range for dashboard, e.g. last 6 hours, last 7 days, etc
|
||||
Time *struct { |
||||
From string `json:"from"` |
||||
To string `json:"to"` |
||||
} `json:"time,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// TODO this appears to be spread all over in the frontend. Concepts will likely need tidying in tandem with schema changes
|
||||
Timepicker *struct { |
||||
// Whether timepicker is collapsed or not.
|
||||
Collapse bool `json:"collapse"` |
||||
|
||||
// Whether timepicker is enabled or not.
|
||||
Enable bool `json:"enable"` |
||||
|
||||
// Whether timepicker is visible or not.
|
||||
Hidden bool `json:"hidden"` |
||||
|
||||
// Selectable intervals for auto-refresh.
|
||||
RefreshIntervals []string `json:"refresh_intervals"` |
||||
} `json:"timepicker,omitempty"` |
||||
|
||||
// Timezone of dashboard,
|
||||
Timezone *ModelTimezone `json:"timezone,omitempty"` |
||||
|
||||
// Title of dashboard.
|
||||
Title *string `json:"title,omitempty"` |
||||
|
||||
// Unique dashboard identifier that can be generated by anyone. string (8-40)
|
||||
Uid *string `json:"uid,omitempty"` |
||||
|
||||
// Version of the dashboard, incremented each time the dashboard is updated.
|
||||
Version *int `json:"version,omitempty"` |
||||
|
||||
// TODO docs
|
||||
WeekStart *string `json:"weekStart,omitempty"` |
||||
} |
||||
|
||||
// DashboardGraphTooltip defines model for Dashboard.GraphTooltip.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelGraphTooltip int |
||||
|
||||
// Theme of dashboard.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelStyle string |
||||
|
||||
// Timezone of dashboard,
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelTimezone string |
||||
|
||||
// TODO docs
|
||||
// FROM: AnnotationQuery in grafana-data/src/types/annotations.ts
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelAnnotationQuery struct { |
||||
BuiltIn int `json:"builtIn"` |
||||
|
||||
// Datasource to use for annotation.
|
||||
Datasource struct { |
||||
Type *string `json:"type,omitempty"` |
||||
Uid *string `json:"uid,omitempty"` |
||||
} `json:"datasource"` |
||||
|
||||
// Whether annotation is enabled.
|
||||
Enable bool `json:"enable"` |
||||
|
||||
// Whether to hide annotation.
|
||||
Hide *bool `json:"hide,omitempty"` |
||||
|
||||
// Annotation icon color.
|
||||
IconColor *string `json:"iconColor,omitempty"` |
||||
|
||||
// Name of annotation.
|
||||
Name *string `json:"name,omitempty"` |
||||
|
||||
// Query for annotation data.
|
||||
RawQuery *string `json:"rawQuery,omitempty"` |
||||
ShowIn int `json:"showIn"` |
||||
|
||||
// Schema for panel targets is specified by datasource
|
||||
// plugins. We use a placeholder definition, which the Go
|
||||
// schema loader either left open/as-is with the Base
|
||||
// variant of the Dashboard and Panel families, or filled
|
||||
// with types derived from plugins in the Instance variant.
|
||||
// When working directly from CUE, importers can extend this
|
||||
// type directly to achieve the same effect.
|
||||
Target *ModelTarget `json:"target,omitempty"` |
||||
Type string `json:"type"` |
||||
} |
||||
|
||||
// 0 for no shared crosshair or tooltip (default).
|
||||
// 1 for shared crosshair.
|
||||
// 2 for shared crosshair AND shared tooltip.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelDashboardCursorSync int |
||||
|
||||
// FROM public/app/features/dashboard/state/DashboardModels.ts - ish
|
||||
// TODO docs
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelDashboardLink struct { |
||||
AsDropdown bool `json:"asDropdown"` |
||||
Icon *string `json:"icon,omitempty"` |
||||
IncludeVars bool `json:"includeVars"` |
||||
KeepTime bool `json:"keepTime"` |
||||
Tags []string `json:"tags"` |
||||
TargetBlank bool `json:"targetBlank"` |
||||
Title string `json:"title"` |
||||
Tooltip *string `json:"tooltip,omitempty"` |
||||
Type ModelDashboardLinkType `json:"type"` |
||||
Url *string `json:"url,omitempty"` |
||||
} |
||||
|
||||
// DashboardDashboardLinkType defines model for DashboardDashboardLink.Type.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelDashboardLinkType string |
||||
|
||||
// TODO docs
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelFieldColor struct { |
||||
// Stores the fixed color value if mode is fixed
|
||||
FixedColor *string `json:"fixedColor,omitempty"` |
||||
|
||||
// The main color scheme mode
|
||||
Mode interface{} `json:"mode"` |
||||
|
||||
// TODO docs
|
||||
SeriesBy *ModelFieldColorSeriesByMode `json:"seriesBy,omitempty"` |
||||
} |
||||
|
||||
// TODO docs
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelFieldColorModeId string |
||||
|
||||
// TODO docs
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelFieldColorSeriesByMode string |
||||
|
||||
// DashboardGraphPanel defines model for dashboard.GraphPanel.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelGraphPanel struct { |
||||
// Support for legacy graph and heatmap panels.
|
||||
Type ModelGraphPanelType `json:"type"` |
||||
} |
||||
|
||||
// Support for legacy graph and heatmap panels.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelGraphPanelType string |
||||
|
||||
// DashboardHeatmapPanel defines model for dashboard.HeatmapPanel.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelHeatmapPanel struct { |
||||
Type ModelHeatmapPanelType `json:"type"` |
||||
} |
||||
|
||||
// DashboardHeatmapPanelType defines model for DashboardHeatmapPanel.Type.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelHeatmapPanelType string |
||||
|
||||
// Dashboard panels. Panels are canonically defined inline
|
||||
// because they share a version timeline with the dashboard
|
||||
// schema; they do not evolve independently.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelPanel struct { |
||||
// The datasource used in all targets.
|
||||
Datasource *struct { |
||||
Type *string `json:"type,omitempty"` |
||||
Uid *string `json:"uid,omitempty"` |
||||
} `json:"datasource,omitempty"` |
||||
|
||||
// Description.
|
||||
Description *string `json:"description,omitempty"` |
||||
FieldConfig struct { |
||||
Defaults struct { |
||||
// TODO docs
|
||||
Color *ModelFieldColor `json:"color,omitempty"` |
||||
|
||||
// custom is specified by the PanelFieldConfig field
|
||||
// in panel plugin schemas.
|
||||
Custom *map[string]interface{} `json:"custom,omitempty"` |
||||
|
||||
// Significant digits (for display)
|
||||
Decimals *float32 `json:"decimals,omitempty"` |
||||
|
||||
// Human readable field metadata
|
||||
Description *string `json:"description,omitempty"` |
||||
|
||||
// The display value for this field. This supports template variables blank is auto
|
||||
DisplayName *string `json:"displayName,omitempty"` |
||||
|
||||
// This can be used by data sources that return and explicit naming structure for values and labels
|
||||
// When this property is configured, this value is used rather than the default naming strategy.
|
||||
DisplayNameFromDS *string `json:"displayNameFromDS,omitempty"` |
||||
|
||||
// True if data source field supports ad-hoc filters
|
||||
Filterable *bool `json:"filterable,omitempty"` |
||||
|
||||
// // The behavior when clicking on a result
|
||||
Links *[]interface{} `json:"links,omitempty"` |
||||
|
||||
// Convert input values into a display string
|
||||
//
|
||||
// TODO this one corresponds to a complex type with
|
||||
// generics on the typescript side. Ouch. Will
|
||||
// either need special care, or we'll just need to
|
||||
// accept a very loosely specified schema. It's very
|
||||
// unlikely we'll be able to translate cue to
|
||||
// typescript generics in the general case, though
|
||||
// this particular one *may* be able to work.
|
||||
Mappings *[]map[string]interface{} `json:"mappings,omitempty"` |
||||
Max *float32 `json:"max,omitempty"` |
||||
Min *float32 `json:"min,omitempty"` |
||||
|
||||
// Alternative to empty string
|
||||
NoValue *string `json:"noValue,omitempty"` |
||||
|
||||
// An explict path to the field in the datasource. When the frame meta includes a path,
|
||||
// This will default to `${frame.meta.path}/${field.name}
|
||||
//
|
||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||
// may be used to update the results
|
||||
Path *string `json:"path,omitempty"` |
||||
Thresholds *ModelThresholdsConfig `json:"thresholds,omitempty"` |
||||
|
||||
// Numeric Options
|
||||
Unit *string `json:"unit,omitempty"` |
||||
|
||||
// True if data source can write a value to the path. Auth/authz are supported separately
|
||||
Writeable *bool `json:"writeable,omitempty"` |
||||
} `json:"defaults"` |
||||
Overrides []struct { |
||||
Matcher struct { |
||||
Id string `json:"id"` |
||||
Options *interface{} `json:"options,omitempty"` |
||||
} `json:"matcher"` |
||||
Properties []struct { |
||||
Id string `json:"id"` |
||||
Value *interface{} `json:"value,omitempty"` |
||||
} `json:"properties"` |
||||
} `json:"overrides"` |
||||
} `json:"fieldConfig"` |
||||
|
||||
// Grid position.
|
||||
GridPos *struct { |
||||
// Panel
|
||||
H int `json:"h"` |
||||
|
||||
// true if fixed
|
||||
Static *bool `json:"static,omitempty"` |
||||
|
||||
// Panel
|
||||
W int `json:"w"` |
||||
|
||||
// Panel x
|
||||
X int `json:"x"` |
||||
|
||||
// Panel y
|
||||
Y int `json:"y"` |
||||
} `json:"gridPos,omitempty"` |
||||
|
||||
// TODO docs
|
||||
Id *int `json:"id,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// TODO tighter constraint
|
||||
Interval *string `json:"interval,omitempty"` |
||||
|
||||
// Panel links.
|
||||
// TODO fill this out - seems there are a couple variants?
|
||||
Links *[]ModelDashboardLink `json:"links,omitempty"` |
||||
|
||||
// TODO docs
|
||||
MaxDataPoints *float32 `json:"maxDataPoints,omitempty"` |
||||
|
||||
// options is specified by the PanelOptions field in panel
|
||||
// plugin schemas.
|
||||
Options map[string]interface{} `json:"options"` |
||||
|
||||
// FIXME this almost certainly has to be changed in favor of scuemata versions
|
||||
PluginVersion *string `json:"pluginVersion,omitempty"` |
||||
|
||||
// Name of template variable to repeat for.
|
||||
Repeat *string `json:"repeat,omitempty"` |
||||
|
||||
// Direction to repeat in if 'repeat' is set.
|
||||
// "h" for horizontal, "v" for vertical.
|
||||
RepeatDirection ModelPanelRepeatDirection `json:"repeatDirection"` |
||||
|
||||
// TODO docs
|
||||
Tags *[]string `json:"tags,omitempty"` |
||||
|
||||
// TODO docs
|
||||
Targets *[]ModelTarget `json:"targets,omitempty"` |
||||
|
||||
// TODO docs
|
||||
Thresholds *[]interface{} `json:"thresholds,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// TODO tighter constraint
|
||||
TimeFrom *string `json:"timeFrom,omitempty"` |
||||
|
||||
// TODO docs
|
||||
TimeRegions *[]interface{} `json:"timeRegions,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// TODO tighter constraint
|
||||
TimeShift *string `json:"timeShift,omitempty"` |
||||
|
||||
// Panel title.
|
||||
Title *string `json:"title,omitempty"` |
||||
Transformations []struct { |
||||
Id string `json:"id"` |
||||
Options map[string]interface{} `json:"options"` |
||||
} `json:"transformations"` |
||||
|
||||
// Whether to display the panel without a background.
|
||||
Transparent bool `json:"transparent"` |
||||
|
||||
// The panel plugin type id. May not be empty.
|
||||
Type string `json:"type"` |
||||
} |
||||
|
||||
// Direction to repeat in if 'repeat' is set.
|
||||
// "h" for horizontal, "v" for vertical.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelPanelRepeatDirection string |
||||
|
||||
// Row panel
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelRowPanel struct { |
||||
Collapsed bool `json:"collapsed"` |
||||
|
||||
// Name of default datasource.
|
||||
Datasource *struct { |
||||
Type *string `json:"type,omitempty"` |
||||
Uid *string `json:"uid,omitempty"` |
||||
} `json:"datasource,omitempty"` |
||||
GridPos *struct { |
||||
// Panel
|
||||
H int `json:"h"` |
||||
|
||||
// true if fixed
|
||||
Static *bool `json:"static,omitempty"` |
||||
|
||||
// Panel
|
||||
W int `json:"w"` |
||||
|
||||
// Panel x
|
||||
X int `json:"x"` |
||||
|
||||
// Panel y
|
||||
Y int `json:"y"` |
||||
} `json:"gridPos,omitempty"` |
||||
Id int `json:"id"` |
||||
Panels []interface{} `json:"panels"` |
||||
|
||||
// Name of template variable to repeat for.
|
||||
Repeat *string `json:"repeat,omitempty"` |
||||
Title *string `json:"title,omitempty"` |
||||
Type ModelRowPanelType `json:"type"` |
||||
} |
||||
|
||||
// DashboardRowPanelType defines model for DashboardRowPanel.Type.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelRowPanelType string |
||||
|
||||
// Schema for panel targets is specified by datasource
|
||||
// plugins. We use a placeholder definition, which the Go
|
||||
// schema loader either left open/as-is with the Base
|
||||
// variant of the Dashboard and Panel families, or filled
|
||||
// with types derived from plugins in the Instance variant.
|
||||
// When working directly from CUE, importers can extend this
|
||||
// type directly to achieve the same effect.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelTarget map[string]interface{} |
||||
|
||||
// TODO docs
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelThreshold struct { |
||||
// TODO docs
|
||||
Color string `json:"color"` |
||||
|
||||
// TODO docs
|
||||
// TODO are the values here enumerable into a disjunction?
|
||||
// Some seem to be listed in typescript comment
|
||||
State *string `json:"state,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
|
||||
Value *float32 `json:"value,omitempty"` |
||||
} |
||||
|
||||
// DashboardThresholdsConfig defines model for dashboard.ThresholdsConfig.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelThresholdsConfig struct { |
||||
Mode ModelThresholdsConfigMode `json:"mode"` |
||||
|
||||
// Must be sorted by 'value', first value is always -Infinity
|
||||
Steps []struct { |
||||
// TODO docs
|
||||
Color string `json:"color"` |
||||
|
||||
// TODO docs
|
||||
// TODO are the values here enumerable into a disjunction?
|
||||
// Some seem to be listed in typescript comment
|
||||
State *string `json:"state,omitempty"` |
||||
|
||||
// TODO docs
|
||||
// FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
|
||||
Value *float32 `json:"value,omitempty"` |
||||
} `json:"steps"` |
||||
} |
||||
|
||||
// DashboardThresholdsConfigMode defines model for DashboardThresholdsConfig.Mode.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelThresholdsConfigMode string |
||||
|
||||
// DashboardThresholdsMode defines model for dashboard.ThresholdsMode.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelThresholdsMode string |
||||
|
||||
// TODO docs
|
||||
// FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelTransformation struct { |
||||
Id string `json:"id"` |
||||
Options map[string]interface{} `json:"options"` |
||||
} |
||||
|
||||
// FROM: packages/grafana-data/src/types/templateVars.ts
|
||||
// TODO docs
|
||||
// TODO what about what's in public/app/features/types.ts?
|
||||
// TODO there appear to be a lot of different kinds of [template] vars here? if so need a disjunction
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelVariableModel struct { |
||||
Label *string `json:"label,omitempty"` |
||||
Name string `json:"name"` |
||||
Type ModelVariableModelType `json:"type"` |
||||
} |
||||
|
||||
// DashboardVariableModelType defines model for DashboardVariableModel.Type.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelVariableModelType string |
||||
|
||||
// FROM: packages/grafana-data/src/types/templateVars.ts
|
||||
// TODO docs
|
||||
// TODO this implies some wider pattern/discriminated union, probably?
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type ModelVariableType string |
||||
|
||||
//go:embed lineage.cue
|
||||
var cueFS embed.FS |
||||
|
||||
// codegen ensures that this is always the latest Thema schema version
|
||||
var currentVersion = thema.SV(0, 0) |
||||
|
||||
// Lineage returns the Thema lineage representing a Grafana dashboard.
|
||||
//
|
||||
// The lineage is the canonical specification of the current dashboard schema,
|
||||
// all prior schema versions, and the mappings that allow migration between
|
||||
// schema versions.
|
||||
func Lineage(lib thema.Library, opts ...thema.BindOption) (thema.Lineage, error) { |
||||
return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, lib, opts...) |
||||
} |
||||
|
||||
var _ thema.LineageFactory = Lineage |
||||
|
||||
// Coremodel contains the foundational schema declaration for dashboards.
|
||||
type Coremodel struct { |
||||
lin thema.Lineage |
||||
} |
||||
|
||||
// Lineage returns the canonical dashboard Lineage.
|
||||
func (c *Coremodel) Lineage() thema.Lineage { |
||||
return c.lin |
||||
} |
||||
|
||||
// CurrentSchema returns the current (latest) dashboard Thema schema.
|
||||
func (c *Coremodel) CurrentSchema() thema.Schema { |
||||
return thema.SchemaP(c.lin, currentVersion) |
||||
} |
||||
|
||||
// GoType returns a pointer to an empty Go struct that corresponds to
|
||||
// the current Thema schema.
|
||||
func (c *Coremodel) GoType() interface{} { |
||||
return &Model{} |
||||
} |
||||
|
||||
func ProvideCoremodel(lib thema.Library) (*Coremodel, error) { |
||||
lin, err := Lineage(lib) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &Coremodel{ |
||||
lin: lin, |
||||
}, nil |
||||
} |
@ -0,0 +1,28 @@ |
||||
// This file is autogenerated. DO NOT EDIT.
|
||||
//
|
||||
// To regenerate, run "make gen-cue" from repository root.
|
||||
//
|
||||
// Derived from the Thema lineage at pkg/coremodel/dashboard
|
||||
|
||||
package dashboard |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
"github.com/grafana/thema" |
||||
) |
||||
|
||||
func TestSchemaAssignability(t *testing.T) { |
||||
lin, err := Lineage(cuectx.ProvideThemaLibrary()) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
sch := thema.SchemaP(lin, currentVersion) |
||||
|
||||
err = thema.AssignableTo(sch, &Model{}) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
} |
@ -1,117 +0,0 @@ |
||||
package dashboard |
||||
|
||||
import ( |
||||
"embed" |
||||
"path/filepath" |
||||
|
||||
"github.com/grafana/thema" |
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
) |
||||
|
||||
var ( |
||||
//go:embed lineage.cue
|
||||
cueFS embed.FS |
||||
|
||||
// TODO: this should be generated by Thema.
|
||||
currentVersion = thema.SV(0, 0) |
||||
) |
||||
|
||||
// HandoffSchemaVersion is the minimum schemaVersion for dashboards at which the
|
||||
// Thema-based dashboard schema is known to be valid.
|
||||
//
|
||||
// schemaVersion is the original version numbering system for dashboards. If a
|
||||
// dashboard is below this schemaVersion, it is necessary for the frontend
|
||||
// typescript dashboard migration logic to first run and get it past this
|
||||
// number, after which Thema can take over.
|
||||
const HandoffSchemaVersion = 36 |
||||
|
||||
// Lineage returns the Thema lineage representing Grafana dashboards. The
|
||||
// lineage is the canonical specification of the current datasource schema, all
|
||||
// prior schema versions, and the mappings that allow migration between schema
|
||||
// versions.
|
||||
//
|
||||
// This is the base variant of the schema, which does not include any composed
|
||||
// plugin schemas.
|
||||
func Lineage(lib thema.Library, opts ...thema.BindOption) (thema.Lineage, error) { |
||||
return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, lib, opts...) |
||||
} |
||||
|
||||
// Model is a dummy struct stand-in for dashboards.
|
||||
//
|
||||
// It exists solely to trick compgen into accepting the dashboard coremodel as valid.
|
||||
type Model struct{} |
||||
|
||||
// model is a hacky Go struct representing a dashboard.
|
||||
//
|
||||
// This exists solely because the coremodel framework enforces that there is a Go struct to which
|
||||
// all valid Thema schema instances can be assigned, per Thema's assignability checker. See
|
||||
// https://github.com/grafana/thema/blob/main/docs/invariants.md#go-assignability for rules.
|
||||
//
|
||||
// DO NOT RELY ON THIS FOR ANYTHING REAL. It is unclear whether we will ever attempt to have a correct, complete
|
||||
// Go struct representation of dashboards, let alone compress it into a single struct.
|
||||
type model struct { |
||||
Title string `json:"title"` |
||||
Description string `json:"description"` |
||||
GnetId string `json:"gnetId"` |
||||
Tags []string `json:"tags"` |
||||
Style string `json:"style"` |
||||
Timezone string `json:"timezone"` |
||||
Editable bool `json:"editable"` |
||||
GraphTooltip uint8 `json:"graphTooltip"` |
||||
Time struct { |
||||
From string `json:"from"` |
||||
To string `json:"to"` |
||||
} `json:"time"` |
||||
Timepicker struct { |
||||
Collapse bool `json:"collapse"` |
||||
Enable bool `json:"enable"` |
||||
Hidden bool `json:"hidden"` |
||||
RefreshIntervals []string `json:"refresh_intervals"` |
||||
} `json:"timepicker"` |
||||
Templating struct { |
||||
List []interface{} `json:"list"` |
||||
} `json:"templating"` |
||||
Annotations struct { |
||||
List []struct { |
||||
Name string `json:"name"` |
||||
Type string `json:"type"` |
||||
BuiltIn uint8 `json:"builtIn"` |
||||
Datasource struct { |
||||
Type string `json:"type"` |
||||
Uid string `json:"uid"` |
||||
} `json:"datasource"` |
||||
Enable bool `json:"enable"` |
||||
Hide bool `json:"hide,omitempty"` |
||||
IconColor string `json:"iconColor"` |
||||
RawQuery string `json:"rawQuery,omitempty"` |
||||
ShowIn int `json:"showIn"` |
||||
Target interface{} `json:"target"` |
||||
} `json:"list"` |
||||
} `json:"annotations"` |
||||
Refresh interface{} `json:"refresh"` // (bool|string)
|
||||
SchemaVersion int `json:"schemaVersion"` |
||||
Links []struct { |
||||
Title string `json:"title"` |
||||
Type string `json:"type"` |
||||
Icon string `json:"icon,omitempty"` |
||||
Tooltip string `json:"tooltip,omitempty"` |
||||
Url string `json:"url,omitempty"` |
||||
Tags []string `json:"tags"` |
||||
AsDropdown bool `json:"asDropdown"` |
||||
TargetBlank bool `json:"targetBlank"` |
||||
IncludeVars bool `json:"includeVars"` |
||||
KeepTime bool `json:"keepTime"` |
||||
} `json:"links"` |
||||
Panels []interface{} `json:"panels"` |
||||
FiscalYearStartMonth uint8 `json:"fiscalYearStartMonth"` |
||||
LiveNow bool `json:"liveNow"` |
||||
WeekStart string `json:"weekStart"` |
||||
|
||||
// //
|
||||
|
||||
Uid string `json:"uid"` |
||||
// OrgId int64 `json:"orgId"`
|
||||
Id int64 `json:"id,omitempty"` |
||||
Version int `json:"version"` |
||||
} |
@ -0,0 +1,93 @@ |
||||
// go:build ignore
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/ioutil" |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
|
||||
"cuelang.org/go/cue/cuecontext" |
||||
gcgen "github.com/grafana/grafana/pkg/codegen" |
||||
"github.com/grafana/thema" |
||||
) |
||||
|
||||
var lib = thema.NewLibrary(cuecontext.New()) |
||||
|
||||
const sep = string(filepath.Separator) |
||||
|
||||
// Generate Go and Typescript implementations for all coremodels, and populate the
|
||||
// coremodel static registry.
|
||||
func main() { |
||||
if len(os.Args) > 1 { |
||||
fmt.Fprintf(os.Stderr, "coremodel code generator does not currently accept any arguments\n, got %q", os.Args) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
cwd, err := os.Getwd() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
// TODO this binds us to only having coremodels in a single directory. If we need more, compgen is the way
|
||||
grootp := strings.Split(cwd, sep) |
||||
groot := filepath.Join(sep, filepath.Join(grootp[:len(grootp)-3]...)) |
||||
|
||||
cmroot := filepath.Join(groot, "pkg", "coremodel") |
||||
tsroot := filepath.Join(groot, "packages", "grafana-schema", "src", "schema") |
||||
|
||||
items, err := ioutil.ReadDir(cmroot) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "could not read coremodels parent dir %s: %s\n", cmroot, err) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
var lins []*gcgen.ExtractedLineage |
||||
for _, item := range items { |
||||
if item.IsDir() { |
||||
lin, err := gcgen.ExtractLineage(filepath.Join(cmroot, item.Name(), "lineage.cue"), lib) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "could not process coremodel dir %s: %s\n", cmroot, err) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
lins = append(lins, lin) |
||||
} |
||||
} |
||||
|
||||
wd := gcgen.NewWriteDiffer() |
||||
for _, ls := range lins { |
||||
wdg, err := ls.GenerateGoCoremodel(filepath.Join(cmroot, ls.Lineage.Name())) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "failed to generate Go for %s: %s\n", ls.Lineage.Name(), err) |
||||
os.Exit(1) |
||||
} |
||||
wd.Merge(wdg) |
||||
|
||||
wdt, err := ls.GenerateTypescriptCoremodel(filepath.Join(tsroot, ls.Lineage.Name())) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "failed to generate TypeScript for %s: %s\n", ls.Lineage.Name(), err) |
||||
os.Exit(1) |
||||
} |
||||
wd.Merge(wdt) |
||||
} |
||||
|
||||
if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { |
||||
err = wd.Verify() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "generated code is not up to date:\n%s\nrun `make gen-cue` to regenerate\n\n", err) |
||||
os.Exit(1) |
||||
} |
||||
} else { |
||||
err = wd.Write() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "error while writing generated code to disk:\n%s\n", err) |
||||
os.Exit(1) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,63 @@ |
||||
// go:build ignore
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
|
||||
"cuelang.org/go/cue/cuecontext" |
||||
"github.com/grafana/grafana/pkg/codegen" |
||||
) |
||||
|
||||
// Generate TypeScript for all plugin models.cue
|
||||
func main() { |
||||
if len(os.Args) > 1 { |
||||
fmt.Fprintf(os.Stderr, "plugin thema code generator does not currently accept any arguments\n, got %q", os.Args) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
cwd, err := os.Getwd() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
var find func(path string) (string, error) |
||||
find = func(path string) (string, error) { |
||||
parent := filepath.Dir(path) |
||||
if parent == path { |
||||
return "", errors.New("grafana root directory could not be found") |
||||
} |
||||
fp := filepath.Join(path, "go.mod") |
||||
if _, err := os.Stat(fp); err == nil { |
||||
return path, nil |
||||
} |
||||
return find(parent) |
||||
} |
||||
groot, err := find(cwd) |
||||
if err != nil { |
||||
fmt.Fprint(os.Stderr, err) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
wd, err := codegen.CuetsifyPlugins(cuecontext.New(), groot) |
||||
|
||||
if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { |
||||
err = wd.Verify() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate\n\n", err) |
||||
os.Exit(1) |
||||
} |
||||
} else { |
||||
err = wd.Write() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "error while writing generated code to disk:\n%s\n", err) |
||||
os.Exit(1) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,3 @@ |
||||
package plugins |
||||
|
||||
//go:generate go run gen.go
|
Loading…
Reference in new issue