@intentius/chant-lexicon-grafana 0.124.31 → 0.124.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/grafana.js +36 -0
- package/dist/actions/index.js +1 -0
- package/dist/alerting-build.js +262 -0
- package/dist/alerting.js +125 -0
- package/dist/annotations.js +47 -0
- package/dist/api/apply.js +320 -0
- package/dist/api/bind.js +44 -0
- package/dist/api/client.js +113 -0
- package/dist/api/converge.js +61 -0
- package/dist/api/dashboards.js +210 -0
- package/dist/api/datasources.js +24 -0
- package/dist/api/fake-grafana-writes.js +185 -0
- package/dist/api/fake-grafana.js +161 -0
- package/dist/api/folders.js +290 -0
- package/dist/api/library-panels.js +82 -0
- package/dist/build.js +767 -0
- package/dist/catalog.js +198 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +130 -0
- package/dist/codegen/generate-cli.js +7 -0
- package/dist/codegen/generate.js +97 -0
- package/dist/codegen/package.js +28 -0
- package/dist/codegen/schema-types.js +159 -0
- package/dist/composites/agent-dashboard.js +460 -0
- package/dist/composites/catalog.js +306 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/red-dashboard.js +135 -0
- package/dist/composites/shared.js +74 -0
- package/dist/composites/slo-alert-rules.js +96 -0
- package/dist/composites/slo-dashboard.js +153 -0
- package/dist/config.js +110 -0
- package/dist/contact-point-check.js +61 -0
- package/dist/contact-point-secrets.js +32 -0
- package/dist/contact-point-settings.gen.js +421 -0
- package/dist/dashboard.js +60 -0
- package/dist/datasource-refs.js +170 -0
- package/dist/datasource-settings.js +57 -0
- package/dist/datasource.js +68 -0
- package/dist/deep-observe-hooks.js +266 -0
- package/dist/deep-observe.js +171 -0
- package/dist/describe-resources.js +249 -0
- package/dist/detect.js +119 -0
- package/dist/export-resources.js +73 -0
- package/dist/folder.js +86 -0
- package/dist/import/alerting.js +514 -0
- package/dist/import/edits.js +90 -0
- package/dist/import/embedded.js +60 -0
- package/dist/import/generator.js +656 -0
- package/dist/import/live-export.js +246 -0
- package/dist/import/mappings.js +317 -0
- package/dist/import/model.js +23 -0
- package/dist/import/normalize-alerting.js +106 -0
- package/dist/import/normalize.js +426 -0
- package/dist/import/operator.js +252 -0
- package/dist/import/parser.js +1480 -0
- package/dist/import/report.js +58 -0
- package/dist/import/testdata/fixtures.js +122 -0
- package/dist/import/v2.js +814 -0
- package/dist/index.js +59 -0
- package/dist/init-templates.js +305 -0
- package/dist/integrity.json +2 -2
- package/dist/k8s.js +548 -0
- package/dist/library-panel.js +83 -0
- package/dist/lint/audit-catalog.js +40 -0
- package/dist/lint/audit-lineage.js +9 -0
- package/dist/lint/post-synth/graf101.js +13 -0
- package/dist/lint/post-synth/graf102.js +13 -0
- package/dist/lint/post-synth/graf103.js +13 -0
- package/dist/lint/post-synth/graf104.js +13 -0
- package/dist/lint/post-synth/graf105.js +13 -0
- package/dist/lint/post-synth/graf106.js +13 -0
- package/dist/lint/post-synth/graf107.js +13 -0
- package/dist/lint/post-synth/graf108.js +13 -0
- package/dist/lint/post-synth/graf109.js +13 -0
- package/dist/lint/post-synth/graf110.js +13 -0
- package/dist/lint/post-synth/graf111.js +13 -0
- package/dist/lint/post-synth/graf112.js +13 -0
- package/dist/lint/post-synth/graf113.js +13 -0
- package/dist/lint/post-synth/graf114.js +13 -0
- package/dist/lint/post-synth/graf115.js +13 -0
- package/dist/lint/post-synth/graf116.js +13 -0
- package/dist/lint/post-synth/graf117.js +13 -0
- package/dist/lint/post-synth/graf118.js +37 -0
- package/dist/lint/post-synth/grafana-helpers.js +89 -0
- package/dist/lint/post-synth/index.js +38 -0
- package/dist/lint/rules/grafana-ast.js +70 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lint/rules/literal-secret.js +109 -0
- package/dist/lint/rules/uid-syntax.js +53 -0
- package/dist/lsp/completions.js +11 -0
- package/dist/lsp/hover.js +29 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/grafana-apply.js +147 -0
- package/dist/op/activities/index.js +7 -0
- package/dist/op/builders.js +19 -0
- package/dist/ownership.js +102 -0
- package/dist/package-cli.js +14 -0
- package/dist/panel-options.js +12 -0
- package/dist/panels.js +296 -0
- package/dist/pin.js +113 -0
- package/dist/plugin.js +172 -0
- package/dist/promql-check.js +176 -0
- package/dist/query-models.js +13 -0
- package/dist/query-syntax.js +129 -0
- package/dist/query.js +227 -0
- package/dist/schema/annotationslist.gen.js +5 -0
- package/dist/schema/azuremonitor.gen.js +5 -0
- package/dist/schema/barchart.gen.js +5 -0
- package/dist/schema/bargauge.gen.js +5 -0
- package/dist/schema/bigquery.gen.js +5 -0
- package/dist/schema/candlestick.gen.js +5 -0
- package/dist/schema/canvas.gen.js +5 -0
- package/dist/schema/cloudwatch.gen.js +5 -0
- package/dist/schema/dashboard.gen.js +6 -0
- package/dist/schema/dashboardlist.gen.js +5 -0
- package/dist/schema/datagrid.gen.js +5 -0
- package/dist/schema/elasticsearch.gen.js +5 -0
- package/dist/schema/expr.gen.js +5 -0
- package/dist/schema/gauge.gen.js +5 -0
- package/dist/schema/geomap.gen.js +5 -0
- package/dist/schema/googlecloudmonitoring.gen.js +5 -0
- package/dist/schema/grafanapyroscope.gen.js +5 -0
- package/dist/schema/heatmap.gen.js +5 -0
- package/dist/schema/histogram.gen.js +5 -0
- package/dist/schema/index.js +40 -0
- package/dist/schema/logs.gen.js +5 -0
- package/dist/schema/loki.gen.js +5 -0
- package/dist/schema/news.gen.js +5 -0
- package/dist/schema/nodegraph.gen.js +5 -0
- package/dist/schema/piechart.gen.js +5 -0
- package/dist/schema/prometheus.gen.js +5 -0
- package/dist/schema/stat.gen.js +5 -0
- package/dist/schema/statetimeline.gen.js +5 -0
- package/dist/schema/statushistory.gen.js +5 -0
- package/dist/schema/table.gen.js +5 -0
- package/dist/schema/tempo.gen.js +5 -0
- package/dist/schema/text.gen.js +5 -0
- package/dist/schema/timeseries.gen.js +5 -0
- package/dist/schema/trend.gen.js +5 -0
- package/dist/schema/xychart.gen.js +5 -0
- package/dist/schema-validate.js +265 -0
- package/dist/serializer.js +40 -0
- package/dist/skill-defs.js +60 -0
- package/dist/spec/fetch-cli.js +26 -0
- package/dist/spec/fetch-notifiers-cli.js +53 -0
- package/dist/spec/fetch-units-cli.js +40 -0
- package/dist/spec/notifiers.js +126 -0
- package/dist/spec/overlay.js +92 -0
- package/dist/spec/schemas.gen.js +42 -0
- package/dist/spec/schemas.js +62 -0
- package/dist/spec/units.gen.js +280 -0
- package/dist/spec/units.js +72 -0
- package/dist/transformations.js +127 -0
- package/dist/util.js +34 -0
- package/dist/validate-alerting.js +444 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate-output.js +635 -0
- package/dist/validate.js +122 -0
- package/dist/validation.js +13 -0
- package/dist/variables.js +70 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/spec/fetch-notifiers-cli.ts +2 -2
- package/src/spec/fetch-units-cli.ts +2 -2
- package/src/spec/overlay.ts +2 -2
- package/src/spec/schemas.ts +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading dashboards (#2946).
|
|
3
|
+
*
|
|
4
|
+
* Grafana 12 and 13 serve dashboards as Kubernetes-style resources under
|
|
5
|
+
* `/apis/dashboard.grafana.app/<version>/namespaces/<ns>/dashboards/<uid>`:
|
|
6
|
+
* the classic dashboard JSON in `spec`, and who manages it in
|
|
7
|
+
* `metadata.annotations` and `metadata.labels` (./../ownership.ts). The
|
|
8
|
+
* version is discovered once per client from the API group, preferring the
|
|
9
|
+
* newest classic (v1-family) version the server serves: `v1` on 13.x,
|
|
10
|
+
* `v1beta1` on 12.x. A server without the group (Grafana 11) is read over
|
|
11
|
+
* `GET /api/dashboards/uid/<uid>`, which has the same JSON and no metadata
|
|
12
|
+
* channel.
|
|
13
|
+
*
|
|
14
|
+
* ## The version branch is one place
|
|
15
|
+
*
|
|
16
|
+
* {@link classicDashboardOf} decides how a stored dashboard is read as
|
|
17
|
+
* classic JSON. Everything downstream (observe, deep observe, export) only
|
|
18
|
+
* ever sees its answer. A dashboard Grafana stores as v2 (the layout-and-
|
|
19
|
+
* elements schema) is not read through Grafana's own v2-to-v1 conversion,
|
|
20
|
+
* which drops what v1 cannot hold with nothing to show it (the importer
|
|
21
|
+
* refuses that read too, `lossyV1Read`). Instead {@link readDashboard} and
|
|
22
|
+
* {@link listDashboards} read it again at the newest v2 version the server
|
|
23
|
+
* serves (`v2` on 13.x, `v2beta1` on 12.x), and `classicDashboardOf`
|
|
24
|
+
* converts that with the importer's v2 reader (./../import/v2.ts), which
|
|
25
|
+
* names everything v2 holds that the classic model cannot (#2947). Only a
|
|
26
|
+
* v2-stored dashboard the server cannot serve at v2, or whose conversion
|
|
27
|
+
* failed, is reported as not readable.
|
|
28
|
+
*/
|
|
29
|
+
import { looksLikeV2Dashboard } from "../detect.js";
|
|
30
|
+
import { lossyV1Read, readV2Dashboard } from "../import/v2.js";
|
|
31
|
+
function isObject(v) {
|
|
32
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
33
|
+
}
|
|
34
|
+
function stringMap(v) {
|
|
35
|
+
if (!isObject(v))
|
|
36
|
+
return {};
|
|
37
|
+
return Object.fromEntries(Object.entries(v).filter(([, x]) => typeof x === "string"));
|
|
38
|
+
}
|
|
39
|
+
export const DASHBOARD_GROUP = "dashboard.grafana.app";
|
|
40
|
+
/** Classic-schema versions of the dashboard API, newest first. */
|
|
41
|
+
export const CLASSIC_DASHBOARD_VERSIONS = ["v1", "v1beta1", "v0alpha1"];
|
|
42
|
+
/** v2-schema versions of the dashboard API the v2 reader understands, newest first. */
|
|
43
|
+
export const V2_DASHBOARD_VERSIONS = ["v2", "v2beta1"];
|
|
44
|
+
/** The dashboard API the server behind `client` serves, discovered once per client. */
|
|
45
|
+
export function dashboardApi(client) {
|
|
46
|
+
return client.once("dashboard-api", async () => {
|
|
47
|
+
const group = await client.get(`/apis/${DASHBOARD_GROUP}`);
|
|
48
|
+
const served = new Set((group?.versions ?? []).map((v) => v.version).filter((v) => typeof v === "string"));
|
|
49
|
+
const version = CLASSIC_DASHBOARD_VERSIONS.find((v) => served.has(v));
|
|
50
|
+
const v2Version = V2_DASHBOARD_VERSIONS.find((v) => served.has(v));
|
|
51
|
+
return version ? { kind: "apis", version, ...(v2Version ? { v2Version } : {}) } : { kind: "legacy" };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** The request path for one dashboard on this API (at `version` instead of the classic one, when given). */
|
|
55
|
+
export function dashboardPath(api, namespace, uid, version) {
|
|
56
|
+
const id = encodeURIComponent(uid);
|
|
57
|
+
return api.kind === "apis"
|
|
58
|
+
? `/apis/${DASHBOARD_GROUP}/${version ?? api.version}/namespaces/${encodeURIComponent(namespace)}/dashboards/${id}`
|
|
59
|
+
: `/api/dashboards/uid/${id}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A classic read of a dashboard Grafana stores as v2 is a lossy
|
|
63
|
+
* down-conversion: read it again at v2, where it is whole. Anything else,
|
|
64
|
+
* and a v2-stored dashboard on a server with no v2 version the reader knows,
|
|
65
|
+
* comes back as it was (and `classicDashboardOf` says why it cannot be read).
|
|
66
|
+
*/
|
|
67
|
+
async function atStoredVersion(client, api, live) {
|
|
68
|
+
if (api.kind !== "apis" || !api.v2Version)
|
|
69
|
+
return live;
|
|
70
|
+
const lossy = lossyV1Read(live.raw);
|
|
71
|
+
if (!lossy || lossy.failed || !lossy.storedVersion.startsWith("v2"))
|
|
72
|
+
return live;
|
|
73
|
+
const address = dashboardPath(api, client.namespace, live.uid, api.v2Version);
|
|
74
|
+
const body = await client.get(address);
|
|
75
|
+
return body === undefined ? live : fromResource(body, address);
|
|
76
|
+
}
|
|
77
|
+
function fromResource(resource, address) {
|
|
78
|
+
const metadata = isObject(resource.metadata) ? resource.metadata : {};
|
|
79
|
+
const annotations = stringMap(metadata.annotations);
|
|
80
|
+
const folderUid = annotations["grafana.app/folder"];
|
|
81
|
+
return {
|
|
82
|
+
uid: typeof metadata.name === "string" ? metadata.name : "",
|
|
83
|
+
raw: resource,
|
|
84
|
+
via: "apis",
|
|
85
|
+
labels: stringMap(metadata.labels),
|
|
86
|
+
annotations,
|
|
87
|
+
...(folderUid ? { folderUid } : {}),
|
|
88
|
+
address,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function fromLegacy(body, address) {
|
|
92
|
+
const dashboard = isObject(body.dashboard) ? body.dashboard : {};
|
|
93
|
+
const meta = isObject(body.meta) ? body.meta : {};
|
|
94
|
+
return {
|
|
95
|
+
uid: typeof dashboard.uid === "string" ? dashboard.uid : "",
|
|
96
|
+
raw: body,
|
|
97
|
+
via: "legacy",
|
|
98
|
+
labels: {},
|
|
99
|
+
annotations: {},
|
|
100
|
+
...(typeof meta.folderUid === "string" && meta.folderUid !== "" ? { folderUid: meta.folderUid } : {}),
|
|
101
|
+
...(typeof meta.folderTitle === "string" && meta.folderTitle !== "" && meta.folderUid ? { folderTitle: meta.folderTitle } : {}),
|
|
102
|
+
address,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Read one dashboard by uid. Throws `GrafanaApiError` for anything but a 2xx or a 404. */
|
|
106
|
+
export async function readDashboard(client, uid) {
|
|
107
|
+
const api = await dashboardApi(client);
|
|
108
|
+
const address = dashboardPath(api, client.namespace, uid);
|
|
109
|
+
const body = await client.get(address);
|
|
110
|
+
if (body === undefined)
|
|
111
|
+
return { absent: true, address };
|
|
112
|
+
if (api.kind !== "apis")
|
|
113
|
+
return { present: fromLegacy(body, address) };
|
|
114
|
+
return { present: await atStoredVersion(client, api, fromResource(body, address)) };
|
|
115
|
+
}
|
|
116
|
+
/** Every dashboard in the organisation, paged. */
|
|
117
|
+
export async function listDashboards(client) {
|
|
118
|
+
const api = await dashboardApi(client);
|
|
119
|
+
const out = [];
|
|
120
|
+
if (api.kind === "apis") {
|
|
121
|
+
const base = `/apis/${DASHBOARD_GROUP}/${api.version}/namespaces/${encodeURIComponent(client.namespace)}/dashboards`;
|
|
122
|
+
let next;
|
|
123
|
+
do {
|
|
124
|
+
const page = await client.get(`${base}?limit=500${next ? `&continue=${encodeURIComponent(next)}` : ""}`);
|
|
125
|
+
for (const item of page?.items ?? []) {
|
|
126
|
+
if (!isObject(item))
|
|
127
|
+
continue;
|
|
128
|
+
const d = fromResource(item, `${base}/${encodeURIComponent(String(item.metadata?.name ?? ""))}`);
|
|
129
|
+
if (d.uid)
|
|
130
|
+
out.push(await atStoredVersion(client, api, d));
|
|
131
|
+
}
|
|
132
|
+
next = page?.metadata?.continue || undefined;
|
|
133
|
+
} while (next);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
for (let page = 1;; page++) {
|
|
137
|
+
const hits = (await client.get(`/api/search?type=dash-db&limit=1000&page=${page}`)) ?? [];
|
|
138
|
+
for (const hit of hits) {
|
|
139
|
+
if (typeof hit.uid !== "string")
|
|
140
|
+
continue;
|
|
141
|
+
const read = await readDashboard(client, hit.uid);
|
|
142
|
+
if ("present" in read)
|
|
143
|
+
out.push(read.present);
|
|
144
|
+
}
|
|
145
|
+
if (hits.length < 1000)
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/** A v2 dashboard (a resource, or a bare v2 spec) as classic JSON with the uid, and what the classic form cannot hold. */
|
|
151
|
+
function fromV2(v2, uid) {
|
|
152
|
+
try {
|
|
153
|
+
const { dashboard, warnings } = readV2Dashboard(v2);
|
|
154
|
+
return { json: { ...dashboard, uid: uid || dashboard.uid }, warnings };
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return { unsupported: err instanceof Error ? err.message : String(err) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The stored dashboard as classic JSON, with its uid, or why it cannot be
|
|
162
|
+
* read as one. The only place the dashboard schema version branches. A v2
|
|
163
|
+
* dashboard is converted by the importer's v2 reader; `warnings` then names
|
|
164
|
+
* what v2 holds that the classic JSON does not (tabs, auto grids,
|
|
165
|
+
* conditional rendering), and is absent for a classic dashboard.
|
|
166
|
+
*/
|
|
167
|
+
export function classicDashboardOf(dashboard) {
|
|
168
|
+
if (dashboard.via === "legacy") {
|
|
169
|
+
const json = isObject(dashboard.raw.dashboard) ? dashboard.raw.dashboard : {};
|
|
170
|
+
if (looksLikeV2Dashboard(json))
|
|
171
|
+
return fromV2(json, dashboard.uid);
|
|
172
|
+
return { json };
|
|
173
|
+
}
|
|
174
|
+
if (looksLikeV2Dashboard(dashboard.raw))
|
|
175
|
+
return fromV2(dashboard.raw, dashboard.uid);
|
|
176
|
+
// The importer's own test for a v1 read of a v2-stored dashboard, so import
|
|
177
|
+
// and observe refuse the same lossy down-conversion. readDashboard has
|
|
178
|
+
// already tried the v2 read; this is a server that could not give it.
|
|
179
|
+
const lossy = lossyV1Read(dashboard.raw);
|
|
180
|
+
if (lossy?.failed)
|
|
181
|
+
return { unsupported: `Grafana could not convert it from ${lossy.storedVersion || "the version it is stored at"} to the classic schema` };
|
|
182
|
+
if (lossy) {
|
|
183
|
+
return {
|
|
184
|
+
unsupported: `it is stored as a ${lossy.storedVersion} dashboard and the server did not serve it at ${V2_DASHBOARD_VERSIONS.join(" or ")}; ` +
|
|
185
|
+
`its ${lossy.apiVersion} copy is a lossy down-conversion, so it is not read`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const spec = isObject(dashboard.raw.spec) ? dashboard.raw.spec : undefined;
|
|
189
|
+
if (!spec)
|
|
190
|
+
return { unsupported: "the resource has no spec" };
|
|
191
|
+
if (looksLikeV2Dashboard(spec))
|
|
192
|
+
return fromV2(spec, dashboard.uid);
|
|
193
|
+
// The resource's name is the dashboard's uid; the spec does not repeat it.
|
|
194
|
+
return { json: { ...spec, uid: dashboard.uid } };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The title of the folder a dashboard is in, or undefined for the General
|
|
198
|
+
* folder. One `GET /api/folders/<uid>` per folder per client.
|
|
199
|
+
*/
|
|
200
|
+
export async function folderTitleOf(client, dashboard) {
|
|
201
|
+
if (!dashboard.folderUid)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (dashboard.folderTitle)
|
|
204
|
+
return dashboard.folderTitle;
|
|
205
|
+
const uid = dashboard.folderUid;
|
|
206
|
+
return client.once(`folder:${uid}`, async () => {
|
|
207
|
+
const folder = await client.get(`/api/folders/${encodeURIComponent(uid)}`);
|
|
208
|
+
return typeof folder?.title === "string" ? folder.title : undefined;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading datasources (#2946), over `GET /api/datasources/uid/<uid>` and
|
|
3
|
+
* `GET /api/datasources`. Grafana 12.4 and 13.2 serve no general
|
|
4
|
+
* datasource resource under `/apis`, so this is the one datasource API both
|
|
5
|
+
* have, and it carries no labels or annotations: a datasource's ownership
|
|
6
|
+
* cannot be read (see ../ownership.ts).
|
|
7
|
+
*
|
|
8
|
+
* The API never returns secret values. `secureJsonFields` says which
|
|
9
|
+
* secrets are set, and that is all this reads of them.
|
|
10
|
+
*/
|
|
11
|
+
export function datasourcePath(uid) {
|
|
12
|
+
return `/api/datasources/uid/${encodeURIComponent(uid)}`;
|
|
13
|
+
}
|
|
14
|
+
/** Read one datasource by uid. Throws `GrafanaApiError` for anything but a 2xx or a 404. */
|
|
15
|
+
export async function readDatasource(client, uid) {
|
|
16
|
+
const address = datasourcePath(uid);
|
|
17
|
+
const body = await client.get(address);
|
|
18
|
+
return body === undefined ? { absent: true, address } : { present: body, address };
|
|
19
|
+
}
|
|
20
|
+
/** Every datasource in the organisation. */
|
|
21
|
+
export async function listDatasources(client) {
|
|
22
|
+
const list = await client.get("/api/datasources");
|
|
23
|
+
return (list ?? []).filter((d) => typeof d === "object" && d !== null && !Array.isArray(d));
|
|
24
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Grafana that remembers writes, in memory, for the applier's tests
|
|
3
|
+
* (#2948). The read-only fake (./fake-grafana.ts) serves a fixed state for
|
|
4
|
+
* observe and export; this one serves the routes the applier writes to and
|
|
5
|
+
* keeps what it was sent, so a second apply sees the first one's result.
|
|
6
|
+
*
|
|
7
|
+
* It behaves as Grafana 12.4.11 and 13.2.2 did when the applier was checked
|
|
8
|
+
* against them (../op/activities/grafana-apply.e2e.test.ts): `POST` to an
|
|
9
|
+
* existing name is a 409, `PUT` needs no `resourceVersion`, a stored
|
|
10
|
+
* dashboard gets the built-in annotation and Grafana's defaults
|
|
11
|
+
* (`storedDashboard`), a folder with anything in it cannot be deleted over
|
|
12
|
+
* `/apis`, and a library element update needs its `version`. With
|
|
13
|
+
* `api: "legacy"` it is a Grafana 11: no `/apis` groups, dashboards over
|
|
14
|
+
* `/api/dashboards/db` and `/api/dashboards/uid`, folders over `/api/folders`.
|
|
15
|
+
*/
|
|
16
|
+
import { storedDashboard } from "./fake-grafana.js";
|
|
17
|
+
function isObject(v) {
|
|
18
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
19
|
+
}
|
|
20
|
+
export function emptyGrafana(api) {
|
|
21
|
+
return { api, dashboards: {}, folders: {}, libraryElements: {} };
|
|
22
|
+
}
|
|
23
|
+
/** What Grafana fills in on a dashboard it stores, beyond `storedDashboard`. */
|
|
24
|
+
const DASHBOARD_DEFAULTS = { editable: true, fiscalYearStartMonth: 0, graphTooltip: 0, links: [], templating: { list: [] }, timepicker: {}, timezone: "", weekStart: "" };
|
|
25
|
+
function store(spec) {
|
|
26
|
+
return { ...DASHBOARD_DEFAULTS, ...storedDashboard(spec) };
|
|
27
|
+
}
|
|
28
|
+
const ok = (json, status = 200) => ({ status, json });
|
|
29
|
+
const notFound = { status: 404, json: { message: "not found" } };
|
|
30
|
+
/**
|
|
31
|
+
* A {@link GrafanaHttp} over `state`, which it changes as it is written to.
|
|
32
|
+
* Every request is recorded in `calls` as `METHOD path`.
|
|
33
|
+
*/
|
|
34
|
+
export function writableGrafana(state, calls = []) {
|
|
35
|
+
const ns = state.namespace ?? "default";
|
|
36
|
+
const apis = state.api !== "legacy";
|
|
37
|
+
const dashRoute = new RegExp(`^/apis/dashboard\\.grafana\\.app/${state.api}/namespaces/${ns}/dashboards(?:/([^/]+))?$`);
|
|
38
|
+
const folderRoute = new RegExp(`^/apis/folder\\.grafana\\.app/${state.api}/namespaces/${ns}/folders(?:/([^/]+))?$`);
|
|
39
|
+
const resource = (kind, group, uid, r) => ({
|
|
40
|
+
apiVersion: `${group}/${state.api}`,
|
|
41
|
+
kind,
|
|
42
|
+
metadata: { name: uid, namespace: ns, resourceVersion: "1", labels: { "grafana.app/deprecatedInternalID": "1", ...r.labels }, annotations: r.annotations },
|
|
43
|
+
spec: r.spec,
|
|
44
|
+
...(kind === "Dashboard" ? { status: { conversion: { failed: false, storedVersion: "v0alpha1" } } } : {}),
|
|
45
|
+
});
|
|
46
|
+
const metadataOf = (body) => {
|
|
47
|
+
const m = isObject(body) && isObject(body.metadata) ? body.metadata : {};
|
|
48
|
+
const strings = (v) => (isObject(v) ? Object.fromEntries(Object.entries(v).filter(([, x]) => typeof x === "string")) : {});
|
|
49
|
+
const labels = strings(m.labels);
|
|
50
|
+
delete labels["grafana.app/deprecatedInternalID"];
|
|
51
|
+
return { name: typeof m.name === "string" ? m.name : "", labels, annotations: strings(m.annotations) };
|
|
52
|
+
};
|
|
53
|
+
const parentOf = (r) => r.annotations["grafana.app/folder"] ?? "";
|
|
54
|
+
const counts = (uid) => ({
|
|
55
|
+
dashboards: Object.values(state.dashboards).filter((d) => parentOf(d) === uid).length,
|
|
56
|
+
folders: Object.values(state.folders).filter((f) => parentOf(f) === uid).length,
|
|
57
|
+
librarypanels: Object.values(state.libraryElements).filter((e) => e.folderUid === uid).length,
|
|
58
|
+
alertrules: 0,
|
|
59
|
+
});
|
|
60
|
+
return async (method, path, body) => {
|
|
61
|
+
calls.push(`${method} ${path}`);
|
|
62
|
+
if (state.status !== undefined)
|
|
63
|
+
return { status: state.status, json: { message: "refused" } };
|
|
64
|
+
const [route] = path.split("?");
|
|
65
|
+
if (route === "/apis/dashboard.grafana.app") {
|
|
66
|
+
if (!apis)
|
|
67
|
+
return notFound;
|
|
68
|
+
const versions = state.api === "v1" ? ["v2", "v0alpha1", "v1", "v1beta1"] : ["v1beta1", "v0alpha1", "v2beta1"];
|
|
69
|
+
return ok({ kind: "APIGroup", versions: versions.map((version) => ({ version })) });
|
|
70
|
+
}
|
|
71
|
+
if (route === "/apis/folder.grafana.app") {
|
|
72
|
+
if (!apis)
|
|
73
|
+
return notFound;
|
|
74
|
+
return ok({ kind: "APIGroup", versions: (state.api === "v1" ? ["v1", "v1beta1"] : ["v1beta1"]).map((version) => ({ version })) });
|
|
75
|
+
}
|
|
76
|
+
for (const [re, kind, group, table] of [
|
|
77
|
+
[dashRoute, "Dashboard", "dashboard.grafana.app", state.dashboards],
|
|
78
|
+
[folderRoute, "Folder", "folder.grafana.app", state.folders],
|
|
79
|
+
]) {
|
|
80
|
+
const m = apis ? re.exec(route) : null;
|
|
81
|
+
if (!m)
|
|
82
|
+
continue;
|
|
83
|
+
const uid = m[1] ? decodeURIComponent(m[1]) : undefined;
|
|
84
|
+
if (!uid) {
|
|
85
|
+
if (method === "GET")
|
|
86
|
+
return ok({ items: Object.entries(table).map(([u, r]) => resource(kind, group, u, r)), metadata: {} });
|
|
87
|
+
if (method === "POST") {
|
|
88
|
+
const meta = metadataOf(body);
|
|
89
|
+
if (table[meta.name])
|
|
90
|
+
return { status: 409, json: { message: "already exists" } };
|
|
91
|
+
const spec = isObject(body) && isObject(body.spec) ? body.spec : {};
|
|
92
|
+
table[meta.name] = { spec: kind === "Dashboard" ? store(spec) : spec, labels: meta.labels, annotations: meta.annotations };
|
|
93
|
+
return ok(resource(kind, group, meta.name, table[meta.name]), 201);
|
|
94
|
+
}
|
|
95
|
+
return { status: 405, json: {} };
|
|
96
|
+
}
|
|
97
|
+
const r = table[uid];
|
|
98
|
+
if (method === "GET")
|
|
99
|
+
return r ? ok(resource(kind, group, uid, r)) : notFound;
|
|
100
|
+
if (method === "PUT") {
|
|
101
|
+
const meta = metadataOf(body);
|
|
102
|
+
const spec = isObject(body) && isObject(body.spec) ? body.spec : {};
|
|
103
|
+
table[uid] = { spec: kind === "Dashboard" ? store(spec) : spec, labels: meta.labels, annotations: meta.annotations };
|
|
104
|
+
return ok(resource(kind, group, uid, table[uid]), r ? 200 : 201);
|
|
105
|
+
}
|
|
106
|
+
if (method === "DELETE") {
|
|
107
|
+
if (!r)
|
|
108
|
+
return notFound;
|
|
109
|
+
if (kind === "Folder" && Object.values(counts(uid)).some((n) => n > 0))
|
|
110
|
+
return { status: 400, json: { message: "Folder cannot be deleted: folder is not empty" } };
|
|
111
|
+
delete table[uid];
|
|
112
|
+
return ok({ status: "Success" });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const counted = /^\/api\/folders\/([^/]+)\/counts$/.exec(route);
|
|
116
|
+
if (counted)
|
|
117
|
+
return state.folders[decodeURIComponent(counted[1])] ? ok(counts(decodeURIComponent(counted[1]))) : notFound;
|
|
118
|
+
// Legacy dashboards and folders (Grafana 11; 12.4 and 13.2 still serve them).
|
|
119
|
+
const legacyDash = /^\/api\/dashboards\/uid\/([^/]+)$/.exec(route);
|
|
120
|
+
if (legacyDash && method === "GET") {
|
|
121
|
+
const uid = decodeURIComponent(legacyDash[1]);
|
|
122
|
+
const d = state.dashboards[uid];
|
|
123
|
+
if (!d)
|
|
124
|
+
return notFound;
|
|
125
|
+
return ok({ dashboard: { ...d.spec, uid, id: 7, version: 3 }, meta: { folderUid: parentOf(d), provisioned: false } });
|
|
126
|
+
}
|
|
127
|
+
if (route === "/api/dashboards/db" && method === "POST" && isObject(body) && isObject(body.dashboard)) {
|
|
128
|
+
const { uid, id: _id, version: _v, ...spec } = body.dashboard;
|
|
129
|
+
const folderUid = typeof body.folderUid === "string" ? body.folderUid : "";
|
|
130
|
+
const prior = state.dashboards[String(uid)];
|
|
131
|
+
state.dashboards[String(uid)] = { spec, labels: prior?.labels ?? {}, annotations: folderUid ? { "grafana.app/folder": folderUid } : {} };
|
|
132
|
+
return ok({ status: "success", uid, version: 1 });
|
|
133
|
+
}
|
|
134
|
+
if (route === "/api/search")
|
|
135
|
+
return ok(Object.keys(state.dashboards).map((uid) => ({ uid, type: "dash-db" })));
|
|
136
|
+
const legacyFolder = /^\/api\/folders\/([^/]+)(\/move)?$/.exec(route);
|
|
137
|
+
if (legacyFolder) {
|
|
138
|
+
const uid = decodeURIComponent(legacyFolder[1]);
|
|
139
|
+
const f = state.folders[uid];
|
|
140
|
+
if (!f)
|
|
141
|
+
return notFound;
|
|
142
|
+
if (legacyFolder[2] && method === "POST" && isObject(body)) {
|
|
143
|
+
f.annotations = typeof body.parentUid === "string" && body.parentUid ? { "grafana.app/folder": body.parentUid } : {};
|
|
144
|
+
return ok({ uid });
|
|
145
|
+
}
|
|
146
|
+
if (method === "GET")
|
|
147
|
+
return ok({ uid, title: f.spec.title, version: 1, ...(parentOf(f) ? { parentUid: parentOf(f) } : {}) });
|
|
148
|
+
if (method === "PUT" && isObject(body)) {
|
|
149
|
+
f.spec = { title: body.title };
|
|
150
|
+
return ok({ uid, title: body.title });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (route === "/api/folders" && method === "POST" && isObject(body)) {
|
|
154
|
+
const uid = String(body.uid);
|
|
155
|
+
if (state.folders[uid])
|
|
156
|
+
return { status: 409, json: { message: "a folder with that uid already exists" } };
|
|
157
|
+
state.folders[uid] = { spec: { title: body.title }, labels: {}, annotations: typeof body.parentUid === "string" && body.parentUid ? { "grafana.app/folder": body.parentUid } : {} };
|
|
158
|
+
return ok({ uid, title: body.title });
|
|
159
|
+
}
|
|
160
|
+
// Library elements: the same on every version.
|
|
161
|
+
if (route === "/api/library-elements" && method === "POST" && isObject(body)) {
|
|
162
|
+
const uid = String(body.uid);
|
|
163
|
+
if (state.libraryElements[uid])
|
|
164
|
+
return { status: 400, json: { message: "library element with that name or UID already exists" } };
|
|
165
|
+
state.libraryElements[uid] = { uid, name: body.name, kind: body.kind, model: body.model, folderUid: body.folderUid ?? "", version: 1 };
|
|
166
|
+
return ok({ result: state.libraryElements[uid] });
|
|
167
|
+
}
|
|
168
|
+
const lib = /^\/api\/library-elements\/([^/]+)$/.exec(route);
|
|
169
|
+
if (lib) {
|
|
170
|
+
const uid = decodeURIComponent(lib[1]);
|
|
171
|
+
const el = state.libraryElements[uid];
|
|
172
|
+
if (!el)
|
|
173
|
+
return notFound;
|
|
174
|
+
if (method === "GET")
|
|
175
|
+
return ok({ result: el });
|
|
176
|
+
if (method === "PATCH" && isObject(body)) {
|
|
177
|
+
if (body.version !== el.version)
|
|
178
|
+
return { status: 400, json: { message: "bad request data" } };
|
|
179
|
+
state.libraryElements[uid] = { ...el, name: body.name, model: body.model, folderUid: body.folderUid ?? "", version: el.version + 1 };
|
|
180
|
+
return ok({ result: state.libraryElements[uid] });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return notFound;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Grafana API in memory, for the observe and export tests (#2946).
|
|
3
|
+
*
|
|
4
|
+
* Serves the routes the readers use, from a state the test sets up, in the
|
|
5
|
+
* shapes Grafana 12.4.11 and 13.2.2 return (checked against both with the
|
|
6
|
+
* e2e in ../observe.e2e.test.ts): the API group discovery, dashboard
|
|
7
|
+
* resources under `/apis`, the legacy `/api/dashboards/uid` and
|
|
8
|
+
* `/api/search`, folders, and datasources. `storedDashboard` does to a built
|
|
9
|
+
* dashboard what Grafana does when it stores one.
|
|
10
|
+
*/
|
|
11
|
+
const BUILTIN_ANNOTATION = {
|
|
12
|
+
builtIn: 1,
|
|
13
|
+
datasource: { type: "grafana", uid: "-- Grafana --" },
|
|
14
|
+
enable: true,
|
|
15
|
+
hide: true,
|
|
16
|
+
iconColor: "rgba(0, 211, 255, 1)",
|
|
17
|
+
name: "Annotations & Alerts",
|
|
18
|
+
type: "dashboard",
|
|
19
|
+
};
|
|
20
|
+
function isObject(v) {
|
|
21
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
function dropNulls(v) {
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(dropNulls);
|
|
26
|
+
if (isObject(v))
|
|
27
|
+
return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== null).map(([k, x]) => [k, dropNulls(x)]));
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
function storePanel(p) {
|
|
31
|
+
const out = { ...p };
|
|
32
|
+
if (isObject(out.options) && Object.keys(out.options).length === 0)
|
|
33
|
+
delete out.options;
|
|
34
|
+
const fc = out.fieldConfig;
|
|
35
|
+
if (isObject(fc) && isObject(fc.defaults) && Object.keys(fc.defaults).length === 0 && Array.isArray(fc.overrides) && fc.overrides.length === 0)
|
|
36
|
+
delete out.fieldConfig;
|
|
37
|
+
if (Array.isArray(out.panels))
|
|
38
|
+
out.panels = out.panels.map((c) => (isObject(c) ? storePanel(c) : c));
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A built dashboard as Grafana hands it back through `/apis` (measured on
|
|
43
|
+
* 12.4.11 and 13.2.2): the built-in annotation added, empty `options` and
|
|
44
|
+
* `fieldConfig` dropped, `null` values dropped, and the uid moved to the
|
|
45
|
+
* resource's name. The legacy route (`/api/dashboards/uid`) serves the
|
|
46
|
+
* built JSON plus `id` and `version`; the fake serves this model on both,
|
|
47
|
+
* which is the harder case for the legacy path.
|
|
48
|
+
*/
|
|
49
|
+
export function storedDashboard(built) {
|
|
50
|
+
const out = dropNulls(built);
|
|
51
|
+
const annotations = isObject(out.annotations) && Array.isArray(out.annotations.list) ? out.annotations.list : [];
|
|
52
|
+
out.annotations = { list: [BUILTIN_ANNOTATION, ...annotations] };
|
|
53
|
+
if (Array.isArray(out.panels))
|
|
54
|
+
out.panels = out.panels.map((p) => (isObject(p) ? storePanel(p) : p));
|
|
55
|
+
delete out.uid;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const ok = (json) => ({ status: 200, json });
|
|
59
|
+
const notFound = { status: 404, json: { message: "not found" } };
|
|
60
|
+
/** A {@link GrafanaHttp} over `state`, recording every request path in `calls`. */
|
|
61
|
+
export function fakeGrafana(state, calls = []) {
|
|
62
|
+
const ns = state.namespace ?? "default";
|
|
63
|
+
const resource = (uid, d) => ({
|
|
64
|
+
apiVersion: `dashboard.grafana.app/${state.api}`,
|
|
65
|
+
kind: "Dashboard",
|
|
66
|
+
metadata: {
|
|
67
|
+
name: uid,
|
|
68
|
+
namespace: ns,
|
|
69
|
+
resourceVersion: "1",
|
|
70
|
+
generation: 1,
|
|
71
|
+
labels: { "grafana.app/deprecatedInternalID": "1", ...(d.labels ?? {}) },
|
|
72
|
+
annotations: { ...(d.folderUid ? { "grafana.app/folder": d.folderUid } : {}), ...(d.annotations ?? {}) },
|
|
73
|
+
},
|
|
74
|
+
spec: d.spec,
|
|
75
|
+
status: { conversion: { failed: false, storedVersion: d.storedVersion ?? "v0alpha1" } },
|
|
76
|
+
});
|
|
77
|
+
return async (method, path) => {
|
|
78
|
+
calls.push(`${method} ${path}`);
|
|
79
|
+
if (state.status !== undefined)
|
|
80
|
+
return { status: state.status, json: { message: "refused" } };
|
|
81
|
+
const [route, query] = path.split("?");
|
|
82
|
+
if (route === "/apis/dashboard.grafana.app") {
|
|
83
|
+
if (state.api === "legacy")
|
|
84
|
+
return notFound;
|
|
85
|
+
const versions = state.api === "v1" ? ["v2", "v2beta1", "v2alpha1", "v0alpha1", "v1", "v1beta1"] : ["v1beta1", "v0alpha1", "v2beta1", "v2alpha1"];
|
|
86
|
+
return ok({ kind: "APIGroup", name: "dashboard.grafana.app", versions: versions.map((version) => ({ groupVersion: `dashboard.grafana.app/${version}`, version })) });
|
|
87
|
+
}
|
|
88
|
+
const list = new RegExp(`^/apis/dashboard\\.grafana\\.app/${state.api}/namespaces/${ns}/dashboards$`).exec(route);
|
|
89
|
+
if (list && state.api !== "legacy")
|
|
90
|
+
return ok({ items: Object.entries(state.dashboards).map(([uid, d]) => resource(uid, d)), metadata: {} });
|
|
91
|
+
const v2 = new RegExp(`^/apis/dashboard\\.grafana\\.app/(v2|v2beta1)/namespaces/${ns}/dashboards/([^/]+)$`).exec(route);
|
|
92
|
+
if (v2 && state.api !== "legacy" && (state.api === "v1" || v2[1] === "v2beta1")) {
|
|
93
|
+
const uid = decodeURIComponent(v2[2]);
|
|
94
|
+
const d = state.dashboards[uid];
|
|
95
|
+
if (!d?.v2)
|
|
96
|
+
return notFound;
|
|
97
|
+
const r = resource(uid, d);
|
|
98
|
+
return ok({ ...r, apiVersion: `dashboard.grafana.app/${v2[1]}`, spec: d.v2, status: {} });
|
|
99
|
+
}
|
|
100
|
+
const one = new RegExp(`^/apis/dashboard\\.grafana\\.app/${state.api}/namespaces/${ns}/dashboards/([^/]+)$`).exec(route);
|
|
101
|
+
if (one && state.api !== "legacy") {
|
|
102
|
+
const d = state.dashboards[decodeURIComponent(one[1])];
|
|
103
|
+
return d ? ok(resource(decodeURIComponent(one[1]), d)) : notFound;
|
|
104
|
+
}
|
|
105
|
+
const legacy = /^\/api\/dashboards\/uid\/([^/]+)$/.exec(route);
|
|
106
|
+
if (legacy) {
|
|
107
|
+
const uid = decodeURIComponent(legacy[1]);
|
|
108
|
+
const d = state.dashboards[uid];
|
|
109
|
+
if (!d)
|
|
110
|
+
return notFound;
|
|
111
|
+
const folderTitle = d.folderUid ? state.folders?.[d.folderUid] : undefined;
|
|
112
|
+
return ok({
|
|
113
|
+
dashboard: { ...d.spec, uid, id: 7, version: 3 },
|
|
114
|
+
meta: { provisioned: true, folderUid: d.folderUid ?? "", folderTitle: folderTitle ?? "Dashboards", version: 3 },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (route === "/api/search") {
|
|
118
|
+
const page = Number(new URLSearchParams(query ?? "").get("page") ?? "1");
|
|
119
|
+
return ok(page === 1 ? Object.keys(state.dashboards).map((uid) => ({ uid, type: "dash-db" })) : []);
|
|
120
|
+
}
|
|
121
|
+
const folder = /^\/api\/folders\/([^/]+)$/.exec(route);
|
|
122
|
+
if (folder) {
|
|
123
|
+
const uid = decodeURIComponent(folder[1]);
|
|
124
|
+
const title = state.folders?.[uid];
|
|
125
|
+
const parentUid = state.folderParents?.[uid];
|
|
126
|
+
return title ? ok({ uid, title, ...(parentUid ? { parentUid } : {}) }) : notFound;
|
|
127
|
+
}
|
|
128
|
+
if (route === "/api/datasources")
|
|
129
|
+
return ok(Object.values(state.datasources ?? {}).map(({ secureJsonFields: _s, ...rest }) => rest));
|
|
130
|
+
const ds = /^\/api\/datasources\/uid\/([^/]+)$/.exec(route);
|
|
131
|
+
if (ds) {
|
|
132
|
+
const d = state.datasources?.[decodeURIComponent(ds[1])];
|
|
133
|
+
return d ? ok(d) : notFound;
|
|
134
|
+
}
|
|
135
|
+
return notFound;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** A datasource as `GET /api/datasources/uid/<uid>` returns one provisioned from a file. */
|
|
139
|
+
export function liveDatasource(ds) {
|
|
140
|
+
return {
|
|
141
|
+
id: 2,
|
|
142
|
+
uid: ds.uid,
|
|
143
|
+
orgId: 1,
|
|
144
|
+
name: ds.name,
|
|
145
|
+
type: ds.type,
|
|
146
|
+
typeLogoUrl: `public/plugins/${ds.type}/img/logo.svg`,
|
|
147
|
+
access: "proxy",
|
|
148
|
+
url: ds.url ?? "",
|
|
149
|
+
user: "",
|
|
150
|
+
database: "",
|
|
151
|
+
basicAuth: false,
|
|
152
|
+
basicAuthUser: "",
|
|
153
|
+
withCredentials: false,
|
|
154
|
+
isDefault: ds.isDefault ?? false,
|
|
155
|
+
jsonData: ds.jsonData ?? {},
|
|
156
|
+
secureJsonFields: Object.fromEntries((ds.secure ?? []).map((k) => [k, true])),
|
|
157
|
+
version: 1,
|
|
158
|
+
readOnly: ds.readOnly ?? true,
|
|
159
|
+
apiVersion: "",
|
|
160
|
+
};
|
|
161
|
+
}
|