schema: Finish converting dashboard schema datasource references to objects (#47806)

* coremodel: finish string -> object datasource ref

Seems we missed updating a couple of the datasource references from
strings to objects.

* cue fmt

* Also fix dashboard in scuemata dashboard schema

* Update devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
pull/47902/head
sam boyer 4 years ago committed by GitHub
parent 031a8e140a
commit 5e11af0121
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      devenv/dev-dashboards/dashboards_test.go
  2. 85
      devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json
  3. 853
      packages/grafana-schema/src/scuemata/dashboard/dashboard.cue
  4. 675
      pkg/coremodel/dashboard/lineage.cue
  5. 11
      pkg/coremodel/dashboard/schema.go
  6. 6
      pkg/schema/load/load_test.go
  7. 2
      scripts/stripnulls.sh

@ -68,7 +68,7 @@ func themaTestableDashboards() (map[string][]byte, error) {
jtree := make(map[string]interface{}) jtree := make(map[string]interface{})
json.Unmarshal(b, &jtree) json.Unmarshal(b, &jtree)
if oldschemav, has := jtree["schemaVersion"]; !has || !(oldschemav.(float64) > 32) { if oldschemav, has := jtree["schemaVersion"]; !has || !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) {
return nil return nil
} }

@ -71,8 +71,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -178,8 +177,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -266,8 +264,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -354,8 +351,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -485,8 +481,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -573,8 +568,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -661,8 +655,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -792,8 +785,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -893,8 +885,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -982,8 +973,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1069,8 +1059,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1155,8 +1144,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1253,8 +1241,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1354,8 +1341,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1455,8 +1441,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1555,8 +1540,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1692,8 +1676,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1800,8 +1783,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -1909,8 +1891,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2040,8 +2021,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2128,8 +2108,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2240,8 +2219,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2356,8 +2334,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2460,8 +2437,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2564,8 +2540,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2701,8 +2676,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2838,8 +2812,7 @@
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {
"color": "green", "color": "green"
"value": null
}, },
{ {
"color": "red", "color": "red",
@ -2934,7 +2907,7 @@
} }
], ],
"refresh": false, "refresh": false,
"schemaVersion": 32, "schemaVersion": 36,
"style": "dark", "style": "dark",
"tags": [ "tags": [
"gdev", "gdev",
@ -2954,4 +2927,4 @@
"uid": "1KxMUdE7k", "uid": "1KxMUdE7k",
"version": 1, "version": 1,
"weekStart": "" "weekStart": ""
} }

@ -1,434 +1,441 @@
package dashboard package dashboard
import ( import (
"list" "list"
"github.com/grafana/grafana/cue/scuemata" "github.com/grafana/grafana/cue/scuemata"
) )
Family: scuemata.#Family & { Family: scuemata.#Family & {
lineages: [ lineages: [
[ [
{ // 0.0 {// 0.0
// Unique numeric identifier for the dashboard. // Unique numeric identifier for the dashboard.
// TODO must isolate or remove identifiers local to a Grafana instance...? // TODO must isolate or remove identifiers local to a Grafana instance...?
id?: number id?: number
// Unique dashboard identifier that can be generated by anyone. string (8-40) // Unique dashboard identifier that can be generated by anyone. string (8-40)
uid?: string uid?: string
// Title of dashboard. // Title of dashboard.
title?: string title?: string
// Description of dashboard. // Description of dashboard.
description?: string description?: string
gnetId?: string gnetId?: string
// Tags associated with dashboard. // Tags associated with dashboard.
tags?: [...string] tags?: [...string]
// Theme of dashboard. // Theme of dashboard.
style: *"light" | "dark" style: *"light" | "dark"
// Timezone of dashboard, // Timezone of dashboard,
timezone?: *"browser" | "utc" | "" timezone?: *"browser" | "utc" | ""
// Whether a dashboard is editable or not. // Whether a dashboard is editable or not.
editable: bool | *true editable: bool | *true
// 0 for no shared crosshair or tooltip (default). // 0 for no shared crosshair or tooltip (default).
// 1 for shared crosshair. // 1 for shared crosshair.
// 2 for shared crosshair AND shared tooltip. // 2 for shared crosshair AND shared tooltip.
graphTooltip: >=0 & <=2 | *0 graphTooltip: >=0 & <=2 | *0
// Time range for dashboard, e.g. last 6 hours, last 7 days, etc // Time range for dashboard, e.g. last 6 hours, last 7 days, etc
time?: { time?: {
from: string | *"now-6h" from: string | *"now-6h"
to: string | *"now" to: string | *"now"
} }
// Timepicker metadata. // Timepicker metadata.
timepicker?: { timepicker?: {
// Whether timepicker is collapsed or not. // Whether timepicker is collapsed or not.
collapse: bool | *false collapse: bool | *false
// Whether timepicker is enabled or not. // Whether timepicker is enabled or not.
enable: bool | *true enable: bool | *true
// Whether timepicker is visible or not. // Whether timepicker is visible or not.
hidden: bool | *false hidden: bool | *false
// Selectable intervals for auto-refresh. // Selectable intervals for auto-refresh.
refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
} }
// Templating. // Templating.
templating?: list: [...{...}] templating?: list: [...{...}]
// Annotations. // Annotations.
annotations?: list: [...{ annotations?: list: [...{
builtIn: number | *0 builtIn: number | *0
// Datasource to use for annotation. // Datasource to use for annotation.
datasource: string datasource: {
// Whether annotation is enabled. type?: string
enable: bool | *true uid?: string
// Whether to hide annotation. }
hide?: bool | *false // Whether annotation is enabled.
// Annotation icon color. enable: bool | *true
iconColor?: string // Whether to hide annotation.
// Name of annotation. hide?: bool | *false
name?: string // Annotation icon color.
type: string | *"dashboard" iconColor?: string
// Query for annotation data. // Name of annotation.
rawQuery?: string name?: string
showIn: number | *0 type: string | *"dashboard"
}] // Query for annotation data.
// Auto-refresh interval. rawQuery?: string
refresh?: string | false showIn: number | *0
// Version of the JSON schema, incremented each time a Grafana update brings }]
// changes to said schema. // Auto-refresh interval.
// FIXME this is the old schema numbering system, and will be replaced by scuemata refresh?: string | false
schemaVersion: number | *30 // Version of the JSON schema, incremented each time a Grafana update brings
// Version of the dashboard, incremented each time the dashboard is updated. // changes to said schema.
version?: number // FIXME this is the old schema numbering system, and will be replaced by scuemata
panels?: [...(#Panel | #GraphPanel | #RowPanel)] schemaVersion: number | *30
// Version of the dashboard, incremented each time the dashboard is updated.
// TODO docs version?: number
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum") panels?: [...(#Panel | #GraphPanel | #RowPanel)]
// TODO docs // TODO docs
#FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum")
// TODO docs // TODO docs
#FieldColor: { #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
// The main color scheme mode
mode: #FieldColorModeId | string // TODO docs
// Stores the fixed color value if mode is fixed #FieldColor: {
fixedColor?: string // The main color scheme mode
// Some visualizations need to know how to assign a series color from by value color schemes mode: #FieldColorModeId | string
seriesBy?: #FieldColorSeriesByMode // Stores the fixed color value if mode is fixed
} @cuetsy(kind="interface") fixedColor?: string
// Some visualizations need to know how to assign a series color from by value color schemes
// TODO docs seriesBy?: #FieldColorSeriesByMode
#Threshold: { } @cuetsy(kind="interface")
// TODO docs
// FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON // TODO docs
value?: number #Threshold: {
// TODO docs // TODO docs
color: string // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
// TODO docs value?: number
// TODO are the values here enumerable into a disjunction? // TODO docs
// Some seem to be listed in typescript comment color: string
state?: string // TODO docs
} @cuetsy(kind="interface") // TODO are the values here enumerable into a disjunction?
// Some seem to be listed in typescript comment
#ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum") state?: string
} @cuetsy(kind="interface")
#ThresholdsConfig: {
mode: #ThresholdsMode #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum")
// Must be sorted by 'value', first value is always -Infinity #ThresholdsConfig: {
steps: [...#Threshold] mode: #ThresholdsMode
} @cuetsy(kind="interface")
// Must be sorted by 'value', first value is always -Infinity
// TODO docs steps: [...#Threshold]
// FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it } @cuetsy(kind="interface")
#Transformation: {
id: string // TODO docs
options: {...} // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
} #Transformation: {
id: string
// Schema for panel targets is specified by datasource options: {...}
// 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 // Schema for panel targets is specified by datasource
// with types derived from plugins in the Instance variant. // plugins. We use a placeholder definition, which the Go
// When working directly from CUE, importers can extend this // schema loader either left open/as-is with the Base
// type directly to achieve the same effect. // variant of the Dashboard and Panel families, or filled
#Target: {...} // with types derived from plugins in the Instance variant.
// When working directly from CUE, importers can extend this
// Dashboard panels. Panels are canonically defined inline // type directly to achieve the same effect.
// because they share a version timeline with the dashboard #Target: {...}
// schema; they do not evolve independently.
#Panel: { // Dashboard panels. Panels are canonically defined inline
// The panel plugin type id. // because they share a version timeline with the dashboard
type: !="" // schema; they do not evolve independently.
#Panel: {
// TODO docs // The panel plugin type id.
id?: number type: !=""
// FIXME this almost certainly has to be changed in favor of scuemata versions // TODO docs
pluginVersion?: string id?: number
// TODO docs // FIXME this almost certainly has to be changed in favor of scuemata versions
tags?: [...string] pluginVersion?: string
// Internal - the exact major and minor versions of the panel plugin // TODO docs
// schema. Hidden and therefore not a part of the data model, but tags?: [...string]
// expected to be filled with panel plugin schema versions so that it's
// possible to figure out which schema version matched on a successful // Internal - the exact major and minor versions of the panel plugin
// unification. // schema. Hidden and therefore not a part of the data model, but
// _pv: { maj: int, min: int } // expected to be filled with panel plugin schema versions so that it's
// The major and minor versions of the panel plugin for this schema. // possible to figure out which schema version matched on a successful
// TODO 2-tuple list instead of struct? // unification.
// panelSchema?: { maj: number, min: number } // _pv: { maj: int, min: int }
panelSchema?: [number, number] // The major and minor versions of the panel plugin for this schema.
// TODO 2-tuple list instead of struct?
// TODO docs // panelSchema?: { maj: number, min: number }
targets?: [...#Target] panelSchema?: [number, number]
// Panel title. // TODO docs
title?: string targets?: [...#Target]
// Description.
description?: string // Panel title.
// Whether to display the panel without a background. title?: string
transparent: bool | *false // Description.
// The datasource used in all targets. description?: string
datasource?: { // Whether to display the panel without a background.
type?: string transparent: bool | *false
uid?: string // The datasource used in all targets.
} datasource?: {
// Grid position. type?: string
gridPos?: { uid?: string
// Panel }
h: number & >0 | *9 // Grid position.
// Panel gridPos?: {
w: number & >0 & <=24 | *12 // Panel
// Panel x h: number & >0 | *9
x: number & >=0 & <24 | *0 // Panel
// Panel y w: number & >0 & <=24 | *12
y: number & >=0 | *0 // Panel x
// true if fixed x: number & >=0 & <24 | *0
static?: bool // Panel y
} y: number & >=0 | *0
// Panel links. // true if fixed
// FIXME this is temporarily specified as a closed list so static?: bool
// that validation will pass when no links are present, but }
// to force a failure as soon as it's checked against there // Panel links.
// being anything in the list so it can be fixed in // FIXME this is temporarily specified as a closed list so
// accordance with that object // that validation will pass when no links are present, but
links?: [] // to force a failure as soon as it's checked against there
// being anything in the list so it can be fixed in
// Name of template variable to repeat for. // accordance with that object
repeat?: string links?: []
// Direction to repeat in if 'repeat' is set.
// "h" for horizontal, "v" for vertical. // Name of template variable to repeat for.
repeatDirection: *"h" | "v" repeat?: string
// Direction to repeat in if 'repeat' is set.
// TODO docs // "h" for horizontal, "v" for vertical.
maxDataPoints?: number repeatDirection: *"h" | "v"
// TODO docs // TODO docs
thresholds?: [...] maxDataPoints?: number
// TODO docs // TODO docs
timeRegions?: [...] thresholds?: [...]
transformations: [...#Transformation] // TODO docs
timeRegions?: [...]
// TODO docs
// TODO tighter constraint transformations: [...#Transformation]
interval?: string
// TODO docs
// TODO docs // TODO tighter constraint
// TODO tighter constraint interval?: string
timeFrom?: string
// TODO docs
// TODO docs // TODO tighter constraint
// TODO tighter constraint timeFrom?: string
timeShift?: string
// TODO docs
// options is specified by the PanelOptions field in panel // TODO tighter constraint
// plugin schemas. timeShift?: string
options: {}
// options is specified by the PanelOptions field in panel
fieldConfig: { // plugin schemas.
defaults: { options: {}
// The display value for this field. This supports template variables blank is auto
displayName?: string fieldConfig: {
defaults: {
// This can be used by data sources that return and explicit naming structure for values and labels // The display value for this field. This supports template variables blank is auto
// When this property is configured, this value is used rather than the default naming strategy. displayName?: string
displayNameFromDS?: string
// This can be used by data sources that return and explicit naming structure for values and labels
// Human readable field metadata // When this property is configured, this value is used rather than the default naming strategy.
description?: string displayNameFromDS?: string
// An explict path to the field in the datasource. When the frame meta includes a path, // Human readable field metadata
// This will default to `${frame.meta.path}/${field.name} description?: string
//
// When defined, this value can be used as an identifier within the datasource scope, and // An explict path to the field in the datasource. When the frame meta includes a path,
// may be used to update the results // This will default to `${frame.meta.path}/${field.name}
path?: string //
// When defined, this value can be used as an identifier within the datasource scope, and
// True if data source can write a value to the path. Auth/authz are supported separately // may be used to update the results
writeable?: bool path?: string
// True if data source field supports ad-hoc filters // True if data source can write a value to the path. Auth/authz are supported separately
filterable?: bool writeable?: bool
// Numeric Options // True if data source field supports ad-hoc filters
unit?: string filterable?: bool
// Significant digits (for display) // Numeric Options
decimals?: number unit?: string
min?: number // Significant digits (for display)
max?: number decimals?: number
// Convert input values into a display string min?: number
// max?: number
// TODO this one corresponds to a complex type with
// generics on the typescript side. Ouch. Will // Convert input values into a display string
// either need special care, or we'll just need to //
// accept a very loosely specified schema. It's very // TODO this one corresponds to a complex type with
// unlikely we'll be able to translate cue to // generics on the typescript side. Ouch. Will
// typescript generics in the general case, though // either need special care, or we'll just need to
// this particular one *may* be able to work. // accept a very loosely specified schema. It's very
mappings?: [...{...}] // unlikely we'll be able to translate cue to
// typescript generics in the general case, though
// Map numeric values to states // this particular one *may* be able to work.
thresholds?: #ThresholdsConfig mappings?: [...{...}]
// // Map values to a display color // Map numeric values to states
color?: #FieldColor thresholds?: #ThresholdsConfig
// // Used when reducing field values // // Map values to a display color
// nullValueMode?: NullValueMode color?: #FieldColor
// // The behavior when clicking on a result // // Used when reducing field values
links?: [...] // nullValueMode?: NullValueMode
// Alternative to empty string // // The behavior when clicking on a result
noValue?: string links?: [...]
// custom is specified by the PanelFieldConfig field // Alternative to empty string
// in panel plugin schemas. noValue?: string
custom?: {}
} // custom is specified by the PanelFieldConfig field
overrides: [...{ // in panel plugin schemas.
matcher: { custom?: {}
id: string | *"" }
options?: _ overrides: [...{
} matcher: {
properties: [...{ id: string | *""
id: string | *"" options?: _
value?: _ }
}] properties: [...{
}] id: string | *""
} value?: _
// Embed the disjunction of all injected panel schema, if any were injected. }]
if len(compose._panelSchemas) > 0 { }]
or(compose._panelSchemas) // TODO try to stick graph in here }
} // Embed the disjunction of all injected panel schema, if any were injected.
if len(compose._panelSchemas) > 0 {
// Make the plugin-composed subtrees open if the panel is or(compose._panelSchemas)// TODO try to stick graph in here
// of unknown types. This is important in every possible case: }
// - Base (this file only): no real dashboard json
// containing any panels would ever validate // Make the plugin-composed subtrees open if the panel is
// - Dist (this file + core plugin schema): dashboard json containing // of unknown types. This is important in every possible case:
// panels with any third-party panel plugins would fail to validate, // - Base (this file only): no real dashboard json
// as well as any core plugins lacking a models.cue. The latter case // containing any panels would ever validate
// is not normally expected, but this is not the appropriate place // - Dist (this file + core plugin schema): dashboard json containing
// to enforce the invariant, anyway. // panels with any third-party panel plugins would fail to validate,
// - Instance (this file + core + third-party plugin schema): dashboard // as well as any core plugins lacking a models.cue. The latter case
// json containing panels with a third-party plugin that exists but // is not normally expected, but this is not the appropriate place
// is not currently installed would fail to validate. // to enforce the invariant, anyway.
if !list.Contains(compose._panelTypes, type) { // - Instance (this file + core + third-party plugin schema): dashboard
options: {...} // json containing panels with a third-party plugin that exists but
fieldConfig: defaults: custom: {...} // is not currently installed would fail to validate.
... if !list.Contains(compose._panelTypes, type) {
} options: {...}
} fieldConfig: defaults: custom: {...}
...
// Row panel }
#RowPanel: { }
type: "row"
collapsed: bool | *false // Row panel
title?: string #RowPanel: {
type: "row"
// Name of default datasource. collapsed: bool | *false
datasource?: string title?: string
gridPos?: { // Name of default datasource.
// Panel datasource: {
h: number & >0 | *9 type?: string
// Panel uid?: string
w: number & >0 & <=24 | *12 }
// Panel x
x: number & >=0 & <24 | *0 gridPos?: {
// Panel y // Panel
y: number & >=0 | *0 h: number & >0 | *9
// true if fixed // Panel
static?: bool w: number & >0 & <=24 | *12
} // Panel x
id: number x: number & >=0 & <24 | *0
panels: [...(#Panel | #GraphPanel)] // Panel y
// Name of template variable to repeat for. y: number & >=0 | *0
repeat?: string // true if fixed
} static?: bool
// Support for legacy graph panels. }
#GraphPanel: { id: number
... panels: [...(#Panel | #GraphPanel)]
type: "graph" // Name of template variable to repeat for.
thresholds: [...{...}] repeat?: string
timeRegions?: [...{...}] }
seriesOverrides: [...{...}]
aliasColors?: [string]: string // Support for legacy graph panels.
bars: bool | *false #GraphPanel: {
dashes: bool | *false ...
dashLength: number | *10 type: "graph"
fill?: number thresholds: [...{...}]
fillGradient?: number timeRegions?: [...{...}]
hiddenSeries: bool | *false seriesOverrides: [...{...}]
legend: {...} aliasColors?: [string]: string
lines: bool | *false bars: bool | *false
linewidth?: number dashes: bool | *false
nullPointMode: *"null" | "connected" | "null as zero" dashLength: number | *10
percentage: bool | *false fill?: number
points: bool | *false fillGradient?: number
pointradius?: number hiddenSeries: bool | *false
renderer: string legend: {...}
spaceLength: number | *10 lines: bool | *false
stack: bool | *false linewidth?: number
steppedLine: bool | *false nullPointMode: *"null" | "connected" | "null as zero"
tooltip?: { percentage: bool | *false
shared?: bool points: bool | *false
sort: number | *0 pointradius?: number
value_type: *"individual" | "cumulative" renderer: string
} spaceLength: number | *10
} stack: bool | *false
} steppedLine: bool | *false
] tooltip?: {
shared?: bool
sort: number | *0
value_type: *"individual" | "cumulative"
}
}
},
],
] ]
compose: { compose: {
// Scuemata families for all panel types that should be composed into the // Scuemata families for all panel types that should be composed into the
// dashboard schema. // dashboard schema.
Panel: [string]: scuemata.#PanelFamily Panel: [string]: scuemata.#PanelFamily
// _panelTypes: [for typ, _ in Panel {typ}] // _panelTypes: [for typ, _ in Panel {typ}]
_panelTypes: [for typ, _ in Panel {typ}, "graph", "row"] _panelTypes: [ for typ, _ in Panel {typ}, "graph", "row"]
_panelSchemas: [for typ, scue in Panel { _panelSchemas: [ for typ, scue in Panel {
for lv, lin in scue.lineages { for lv, lin in scue.lineages {
for sv, sch in lin { for sv, sch in lin {
(_mapPanel & {arg: { (_mapPanel & {arg: {
type: typ type: typ
v: [lv, sv] // TODO add optionality for exact, at least, at most, any v: [lv, sv] // TODO add optionality for exact, at least, at most, any
model: sch // TODO Does this need to be close()d? model: sch // TODO Does this need to be close()d?
}}).out }}).out
} }
} }
}, { type: string }] }, {type: string}]
_mapPanel: { _mapPanel: {
arg: { arg: {
type: string & !="" type: string & !=""
v: [number, number] v: [number, number]
model: {...} model: {...}
} }
// Until CUE introduces the must() constraint, we have to enforce // Until CUE introduces the must() constraint, we have to enforce
// that the input model is as expected by checking for unification // that the input model is as expected by checking for unification
// in this hidden property (see https://github.com/cue-lang/cue/issues/575). // in this hidden property (see https://github.com/cue-lang/cue/issues/575).
// If we unified arg.model with the scuemata.#PanelSchema // If we unified arg.model with the scuemata.#PanelSchema
// meta-schema directly, the struct openness (PanelOptions: {...}) // meta-schema directly, the struct openness (PanelOptions: {...})
// would be applied to the actual schema instance in the arg. Here, // would be applied to the actual schema instance in the arg. Here,
// where we're actually putting those in the dashboard schema, want // where we're actually putting those in the dashboard schema, want
// those to be closed, or at least preserve closed-ness. // those to be closed, or at least preserve closed-ness.
_checkSchema: scuemata.#PanelSchema & arg.model _checkSchema: scuemata.#PanelSchema & arg.model
out: { out: {
type: arg.type type: arg.type
panelSchema: arg.v // TODO add optionality for exact, at least, at most, any panelSchema: arg.v // TODO add optionality for exact, at least, at most, any
options: arg.model.PanelOptions options: arg.model.PanelOptions
fieldConfig: defaults: custom: {} fieldConfig: defaults: custom: {}
if arg.model.PanelFieldConfig != _|_ { if arg.model.PanelFieldConfig != _|_ {
fieldConfig: defaults: custom: arg.model.PanelFieldConfig fieldConfig: defaults: custom: arg.model.PanelFieldConfig
} }
} }
} }
} }
} }

@ -7,338 +7,345 @@ name: "dashboard"
seqs: [ seqs: [
{ {
schemas: [ schemas: [
{ // 0.0 {// 0.0
// Unique numeric identifier for the dashboard. // Unique numeric identifier for the dashboard.
// TODO must isolate or remove identifiers local to a Grafana instance...? // TODO must isolate or remove identifiers local to a Grafana instance...?
id?: int64 id?: int64
// Unique dashboard identifier that can be generated by anyone. string (8-40) // Unique dashboard identifier that can be generated by anyone. string (8-40)
uid?: string uid?: string
// Title of dashboard. // Title of dashboard.
title?: string title?: string
// Description of dashboard. // Description of dashboard.
description?: string description?: string
gnetId?: string gnetId?: string
// Tags associated with dashboard. // Tags associated with dashboard.
tags?: [...string] tags?: [...string]
// Theme of dashboard. // Theme of dashboard.
style: *"light" | "dark" style: *"light" | "dark"
// Timezone of dashboard, // Timezone of dashboard,
timezone?: *"browser" | "utc" | "" timezone?: *"browser" | "utc" | ""
// Whether a dashboard is editable or not. // Whether a dashboard is editable or not.
editable: bool | *true editable: bool | *true
// 0 for no shared crosshair or tooltip (default). // 0 for no shared crosshair or tooltip (default).
// 1 for shared crosshair. // 1 for shared crosshair.
// 2 for shared crosshair AND shared tooltip. // 2 for shared crosshair AND shared tooltip.
graphTooltip: uint8 & >=0 & <=2 | *0 graphTooltip: uint8 & >=0 & <=2 | *0
// Time range for dashboard, e.g. last 6 hours, last 7 days, etc // Time range for dashboard, e.g. last 6 hours, last 7 days, etc
time?: { time?: {
from: string | *"now-6h" from: string | *"now-6h"
to: string | *"now" to: string | *"now"
} }
// Timepicker metadata. // Timepicker metadata.
timepicker?: { timepicker?: {
// Whether timepicker is collapsed or not. // Whether timepicker is collapsed or not.
collapse: bool | *false collapse: bool | *false
// Whether timepicker is enabled or not. // Whether timepicker is enabled or not.
enable: bool | *true enable: bool | *true
// Whether timepicker is visible or not. // Whether timepicker is visible or not.
hidden: bool | *false hidden: bool | *false
// Selectable intervals for auto-refresh. // Selectable intervals for auto-refresh.
refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
} }
// Templating. // Templating.
templating?: list: [...{...}] templating?: list: [...{...}]
// Annotations. // Annotations.
annotations?: list: [...{ annotations?: list: [...{
builtIn: uint8 | *0 builtIn: uint8 | *0
// Datasource to use for annotation. // Datasource to use for annotation.
datasource: string datasource: {
// Whether annotation is enabled. type?: string
enable: bool | *true uid?: string
// Whether to hide annotation. }
hide?: bool | *false // Whether annotation is enabled.
// Annotation icon color. enable: bool | *true
iconColor?: string // Whether to hide annotation.
// Name of annotation. hide?: bool | *false
name?: string // Annotation icon color.
type: string | *"dashboard" iconColor?: string
// Query for annotation data. // Name of annotation.
rawQuery?: string name?: string
showIn: uint8 | *0 type: string | *"dashboard"
}] // Query for annotation data.
// Auto-refresh interval. rawQuery?: string
refresh?: string | false showIn: uint8 | *0
// Version of the JSON schema, incremented each time a Grafana update brings }]
// changes to said schema. // Auto-refresh interval.
// FIXME this is the old schema numbering system, and will be replaced by Thema's themaVersion refresh?: string | false
schemaVersion: uint16 | *33 // Version of the JSON schema, incremented each time a Grafana update brings
// Version of the dashboard, incremented each time the dashboard is updated. // changes to said schema.
version?: uint32 // FIXME this is the old schema numbering system, and will be replaced by Thema's themaVersion
panels?: [...(#Panel | #GraphPanel | #HeatmapPanel | #RowPanel)] schemaVersion: uint16 | *33
// Version of the dashboard, incremented each time the dashboard is updated.
// TODO docs version?: uint32
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum") panels?: [...(#Panel | #GraphPanel | #HeatmapPanel | #RowPanel)]
// TODO docs // TODO docs
#FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum")
// TODO docs // TODO docs
#FieldColor: { #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
// The main color scheme mode
mode: #FieldColorModeId | string // TODO docs
// Stores the fixed color value if mode is fixed #FieldColor: {
fixedColor?: string // The main color scheme mode
// Some visualizations need to know how to assign a series color from by value color schemes mode: #FieldColorModeId | string
seriesBy?: #FieldColorSeriesByMode // Stores the fixed color value if mode is fixed
} @cuetsy(kind="interface") fixedColor?: string
// Some visualizations need to know how to assign a series color from by value color schemes
// TODO docs seriesBy?: #FieldColorSeriesByMode
#Threshold: { } @cuetsy(kind="interface")
// TODO docs
// FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON // TODO docs
value?: number #Threshold: {
// TODO docs // TODO docs
color: string // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
// TODO docs value?: number
// TODO are the values here enumerable into a disjunction? // TODO docs
// Some seem to be listed in typescript comment color: string
state?: string // TODO docs
} @cuetsy(kind="interface") // TODO are the values here enumerable into a disjunction?
// Some seem to be listed in typescript comment
#ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum") state?: string
} @cuetsy(kind="interface")
#ThresholdsConfig: {
mode: #ThresholdsMode #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum")
// Must be sorted by 'value', first value is always -Infinity #ThresholdsConfig: {
steps: [...#Threshold] mode: #ThresholdsMode
} @cuetsy(kind="interface")
// Must be sorted by 'value', first value is always -Infinity
// TODO docs steps: [...#Threshold]
// FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it } @cuetsy(kind="interface")
#Transformation: {
id: string // TODO docs
options: {...} // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
} #Transformation: {
id: string
// Schema for panel targets is specified by datasource options: {...}
// 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 // Schema for panel targets is specified by datasource
// with types derived from plugins in the Instance variant. // plugins. We use a placeholder definition, which the Go
// When working directly from CUE, importers can extend this // schema loader either left open/as-is with the Base
// type directly to achieve the same effect. // variant of the Dashboard and Panel families, or filled
#Target: {...} // with types derived from plugins in the Instance variant.
// When working directly from CUE, importers can extend this
// Dashboard panels. Panels are canonically defined inline // type directly to achieve the same effect.
// because they share a version timeline with the dashboard #Target: {...}
// schema; they do not evolve independently.
#Panel: { // Dashboard panels. Panels are canonically defined inline
// The panel plugin type id. // because they share a version timeline with the dashboard
type: !="" // schema; they do not evolve independently.
#Panel: {
// TODO docs // The panel plugin type id.
id?: uint32 type: !=""
// FIXME this almost certainly has to be changed in favor of scuemata versions // TODO docs
pluginVersion?: string id?: uint32
// TODO docs // FIXME this almost certainly has to be changed in favor of scuemata versions
tags?: [...string] pluginVersion?: string
// Internal - the exact major and minor versions of the panel plugin // TODO docs
// schema. Hidden and therefore not a part of the data model, but tags?: [...string]
// expected to be filled with panel plugin schema versions so that it's
// possible to figure out which schema version matched on a successful // Internal - the exact major and minor versions of the panel plugin
// unification. // schema. Hidden and therefore not a part of the data model, but
// _pv: { maj: int, min: int } // expected to be filled with panel plugin schema versions so that it's
// The major and minor versions of the panel plugin for this schema. // possible to figure out which schema version matched on a successful
// TODO 2-tuple list instead of struct? // unification.
// panelSchema?: { maj: number, min: number } // _pv: { maj: int, min: int }
panelSchema?: [number, number] // The major and minor versions of the panel plugin for this schema.
// TODO 2-tuple list instead of struct?
// TODO docs // panelSchema?: { maj: number, min: number }
targets?: [...#Target] panelSchema?: [number, number]
// Panel title. // TODO docs
title?: string targets?: [...#Target]
// Description.
description?: string // Panel title.
// Whether to display the panel without a background. title?: string
transparent: bool | *false // Description.
// The datasource used in all targets. description?: string
datasource?: { // Whether to display the panel without a background.
type?: string transparent: bool | *false
uid?: string // The datasource used in all targets.
} datasource?: {
// Grid position. type?: string
gridPos?: { uid?: string
// Panel }
h: uint32 & >0 | *9 // Grid position.
// Panel gridPos?: {
w: uint32 & >0 & <=24 | *12 // Panel
// Panel x h: uint32 & >0 | *9
x: uint32 & >=0 & <24 | *0 // Panel
// Panel y w: uint32 & >0 & <=24 | *12
y: uint32 & >=0 | *0 // Panel x
// true if fixed x: uint32 & >=0 & <24 | *0
static?: bool // Panel y
} y: uint32 & >=0 | *0
// Panel links. // true if fixed
// FIXME this is temporarily specified as a closed list so static?: bool
// that validation will pass when no links are present, but }
// to force a failure as soon as it's checked against there // Panel links.
// being anything in the list so it can be fixed in // FIXME this is temporarily specified as a closed list so
// accordance with that object // that validation will pass when no links are present, but
links?: [] // to force a failure as soon as it's checked against there
// being anything in the list so it can be fixed in
// Name of template variable to repeat for. // accordance with that object
repeat?: string links?: []
// Direction to repeat in if 'repeat' is set.
// "h" for horizontal, "v" for vertical. // Name of template variable to repeat for.
repeatDirection: *"h" | "v" repeat?: string
// Direction to repeat in if 'repeat' is set.
// TODO docs // "h" for horizontal, "v" for vertical.
maxDataPoints?: number repeatDirection: *"h" | "v"
// TODO docs // TODO docs
thresholds?: [...] maxDataPoints?: number
// TODO docs // TODO docs
timeRegions?: [...] thresholds?: [...]
transformations: [...#Transformation] // TODO docs
timeRegions?: [...]
// TODO docs
// TODO tighter constraint transformations: [...#Transformation]
interval?: string
// TODO docs
// TODO docs // TODO tighter constraint
// TODO tighter constraint interval?: string
timeFrom?: string
// TODO docs
// TODO docs // TODO tighter constraint
// TODO tighter constraint timeFrom?: string
timeShift?: string
// TODO docs
// options is specified by the PanelOptions field in panel // TODO tighter constraint
// plugin schemas. timeShift?: string
options: {...}
// options is specified by the PanelOptions field in panel
fieldConfig: { // plugin schemas.
defaults: { options: {...}
// The display value for this field. This supports template variables blank is auto
displayName?: string fieldConfig: {
defaults: {
// This can be used by data sources that return and explicit naming structure for values and labels // The display value for this field. This supports template variables blank is auto
// When this property is configured, this value is used rather than the default naming strategy. displayName?: string
displayNameFromDS?: string
// This can be used by data sources that return and explicit naming structure for values and labels
// Human readable field metadata // When this property is configured, this value is used rather than the default naming strategy.
description?: string displayNameFromDS?: string
// An explict path to the field in the datasource. When the frame meta includes a path, // Human readable field metadata
// This will default to `${frame.meta.path}/${field.name} description?: string
//
// When defined, this value can be used as an identifier within the datasource scope, and // An explict path to the field in the datasource. When the frame meta includes a path,
// may be used to update the results // This will default to `${frame.meta.path}/${field.name}
path?: string //
// When defined, this value can be used as an identifier within the datasource scope, and
// True if data source can write a value to the path. Auth/authz are supported separately // may be used to update the results
writeable?: bool path?: string
// True if data source field supports ad-hoc filters // True if data source can write a value to the path. Auth/authz are supported separately
filterable?: bool writeable?: bool
// Numeric Options // True if data source field supports ad-hoc filters
unit?: string filterable?: bool
// Significant digits (for display) // Numeric Options
decimals?: number unit?: string
min?: number // Significant digits (for display)
max?: number decimals?: number
// Convert input values into a display string min?: number
// max?: number
// TODO this one corresponds to a complex type with
// generics on the typescript side. Ouch. Will // Convert input values into a display string
// either need special care, or we'll just need to //
// accept a very loosely specified schema. It's very // TODO this one corresponds to a complex type with
// unlikely we'll be able to translate cue to // generics on the typescript side. Ouch. Will
// typescript generics in the general case, though // either need special care, or we'll just need to
// this particular one *may* be able to work. // accept a very loosely specified schema. It's very
mappings?: [...{...}] // unlikely we'll be able to translate cue to
// typescript generics in the general case, though
// Map numeric values to states // this particular one *may* be able to work.
thresholds?: #ThresholdsConfig mappings?: [...{...}]
// // Map values to a display color // Map numeric values to states
color?: #FieldColor thresholds?: #ThresholdsConfig
// // Used when reducing field values // // Map values to a display color
// nullValueMode?: NullValueMode color?: #FieldColor
// // The behavior when clicking on a result // // Used when reducing field values
links?: [...] // nullValueMode?: NullValueMode
// Alternative to empty string // // The behavior when clicking on a result
noValue?: string links?: [...]
// custom is specified by the PanelFieldConfig field // Alternative to empty string
// in panel plugin schemas. noValue?: string
custom?: {...}
} // custom is specified by the PanelFieldConfig field
overrides: [...{ // in panel plugin schemas.
matcher: { custom?: {...}
id: string | *"" }
options?: _ overrides: [...{
} matcher: {
properties: [...{ id: string | *""
id: string | *"" options?: _
value?: _ }
}] properties: [...{
}] id: string | *""
} value?: _
} }]
}]
// Row panel }
#RowPanel: { }
type: "row"
collapsed: bool | *false // Row panel
title?: string #RowPanel: {
type: "row"
// Name of default datasource. collapsed: bool | *false
datasource?: string title?: string
gridPos?: { // Name of default datasource.
// Panel datasource?: {
h: uint32 & >0 | *9 type?: string
// Panel uid?: string
w: uint32 & >0 & <=24 | *12 }
// Panel x
x: uint32 & >=0 & <24 | *0 gridPos?: {
// Panel y // Panel
y: uint32 & >=0 | *0 h: uint32 & >0 | *9
// true if fixed // Panel
static?: bool w: uint32 & >0 & <=24 | *12
} // Panel x
id: uint32 x: uint32 & >=0 & <24 | *0
panels: [...(#Panel | #GraphPanel | #HeatmapPanel)] // Panel y
// Name of template variable to repeat for. y: uint32 & >=0 | *0
repeat?: string // true if fixed
} static?: bool
// Support for legacy graph and heatmap panels. }
#GraphPanel: { id: uint32
... panels: [...(#Panel | #GraphPanel | #HeatmapPanel)]
type: "graph" // Name of template variable to repeat for.
} repeat?: string
#HeatmapPanel: { }
...
type: "heatmap" // Support for legacy graph and heatmap panels.
} #GraphPanel: {
} type: "graph"
] ...
} }
#HeatmapPanel: {
type: "heatmap"
...
}
},
]
},
] ]

@ -17,6 +17,15 @@ var (
currentVersion = thema.SV(0, 0) 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 returns the Thema lineage representing Grafana dashboards. The
// lineage is the canonical specification of the current datasource schema, all // lineage is the canonical specification of the current datasource schema, all
// prior schema versions, and the mappings that allow migration between schema // prior schema versions, and the mappings that allow migration between schema
@ -80,7 +89,7 @@ type model struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
Panels []interface{} `json:"panels"` Panels []interface{} `json:"panels"`
//// // //
Uid string `json:"uid"` Uid string `json:"uid"`
// OrgId int64 `json:"orgId"` // OrgId int64 `json:"orgId"`

@ -19,6 +19,7 @@ import (
"cuelang.org/go/cue/errors" "cuelang.org/go/cue/errors"
"cuelang.org/go/cue/load" "cuelang.org/go/cue/load"
cuejson "cuelang.org/go/pkg/encoding/json" cuejson "cuelang.org/go/pkg/encoding/json"
"github.com/grafana/grafana/pkg/coremodel/dashboard"
"github.com/grafana/grafana/pkg/schema" "github.com/grafana/grafana/pkg/schema"
"github.com/laher/mergefs" "github.com/laher/mergefs"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -48,7 +49,6 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f
b, err := os.Open(path) b, err := os.Open(path)
require.NoError(t, err, "failed to open dashboard file") require.NoError(t, err, "failed to open dashboard file")
// Only try to validate dashboards with schemaVersion >= 30
jtree := make(map[string]interface{}) jtree := make(map[string]interface{})
byt, err := io.ReadAll(b) byt, err := io.ReadAll(b)
if err != nil { if err != nil {
@ -59,9 +59,9 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f
t.Logf("no schemaVersion in %s", path) t.Logf("no schemaVersion in %s", path)
return nil return nil
} else { } else {
if !(oldschemav.(float64) > 32) { if !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) {
if testing.Verbose() { if testing.Verbose() {
t.Logf("schemaVersion is %v, older than 33, skipping %s", oldschemav, path) t.Logf("schemaVersion is %v, older than %v, skipping %s", oldschemav, dashboard.HandoffSchemaVersion-1, path)
} }
return nil return nil
} }

@ -7,7 +7,7 @@
SED=$(command -v gsed) SED=$(command -v gsed)
SED=${SED:-"sed"} SED=${SED:-"sed"}
FILES=$(grep -rl '"schemaVersion": 3[34]' devenv) FILES=$(grep -rl '"schemaVersion": 3[3456]' devenv)
set -e set -e
set -x set -x
for DASH in ${FILES}; do echo "${DASH}"; grep -v 'null,$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done for DASH in ${FILES}; do echo "${DASH}"; grep -v 'null,$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done

Loading…
Cancel
Save