Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 352ac8fc5b | |||
| 00917091d7 | |||
| d73e4a229f | |||
| bba5c44dc4 | |||
| 44e6ea3d8b | |||
| 014d4758c6 | |||
| 82b4ce0ece | |||
| 52698cf0da | |||
| d291dfb35b | |||
| 9c6feb8de5 | |||
| e7625186af | |||
| 96e3fdbfd5 | |||
| 68a65af091 | |||
| 82c045e501 | |||
| 80d806a822 | |||
| 570146fb36 | |||
| 61ec394f59 | |||
| ad0adf79bd | |||
| 1f396581a6 | |||
| 406502f351 | |||
| 9648c0956f | |||
| 008d373f7a | |||
| 3f1145fe3b | |||
| 09c5311797 | |||
| 6df663584c | |||
| 2614a917de | |||
| a1015f7a9f | |||
| f17765b70c | |||
| 9a05906299 | |||
| 969ae75b08 | |||
| eeb6d105ed | |||
| b93fb964b7 | |||
| d1657d4684 | |||
| 63374d29c0 | |||
| 379aff5ff4 | |||
| 8bf8c07878 | |||
| f7dc2f6e56 | |||
| 704f533846 | |||
| 82312c3418 | |||
| 87a9f26997 | |||
| 1fe4415682 | |||
| a629b70c1f | |||
| 36fe8c6b61 | |||
| e96dd1b12a | |||
| a4adcf8896 | |||
| 1671a8644f | |||
| bfcf649e8b | |||
| ebb4cfadff | |||
| 29e9ae1918 | |||
| 540eb6c862 | |||
| 17b20fb464 | |||
| dc986afd68 | |||
| eba83d8973 | |||
| cf89fb2a13 | |||
| 290e8a97f1 | |||
| 0ed24434c2 | |||
| cbfb1e15ed | |||
| 47e379c8d9 | |||
| 500b029b25 | |||
| bce28b8663 | |||
| b0c9350580 | |||
| 76b4c687b0 | |||
| 0d6f718255 | |||
| 6b72ddb4d3 | |||
| 2f094fdcd9 | |||
| 6a0ce01d18 | |||
| d00b8ab76d | |||
| e577b8d0b8 | |||
| 2270d6cb22 | |||
| c539be48d8 | |||
| 03a2153bd8 | |||
| eb01a3e462 | |||
| f6b6b62f5e | |||
| 5a2351387a | |||
| 78d9829d3b | |||
| d9e1adaa48 | |||
| 949b521ac7 | |||
| 1d9572cdb2 | |||
| f5e649183b | |||
| 4b44a83802 | |||
| 26dc101ecc | |||
| ca2f2e8d9e | |||
| 0e10d9cd16 | |||
| ed9789179b | |||
| 5890137232 | |||
| 471c4eb89d | |||
| 3ac63ea9c4 | |||
| 7d2d38fd94 | |||
| 80dda87868 | |||
| 04a4fd7c61 | |||
| 1d26b455fd | |||
| 8f0109a1ee | |||
| 798e9a32fc | |||
| f4001d7cdc | |||
| 718c28438e | |||
| b58b9144a5 | |||
| 96830de552 | |||
| 58c956eb19 | |||
| b1d8e9ca41 | |||
| 4006a61964 | |||
| 0a24935e45 | |||
| f25c9b0e03 | |||
| 5c478e98c4 | |||
| ddc99a1dca | |||
| e12d1ba6ca | |||
| 420e070aea | |||
| 732d4351de | |||
| f842bb7af7 | |||
| a1e2ba6617 | |||
| 1cade082fc | |||
| ea38c4ad5a | |||
| 01926ab3c8 | |||
| 84a5282ea2 | |||
| 35d7bb880a | |||
| 251b7b4b4e | |||
| 4e3197a58f | |||
| 1a883d1167 | |||
| cc2e96a558 | |||
| 6b2ebb2d65 | |||
| 7cc36672bb | |||
| 5be096833a | |||
| 076d1a5ad5 | |||
| 775ed81b58 | |||
| fb0aaa321e | |||
| c1c1f3a85c | |||
| c0f1a6423c | |||
| a44fdee0ef | |||
| 97a089ef05 | |||
| b2799f977f | |||
| 2d4fd99e7a | |||
| c4842845e7 | |||
| e9105e6303 | |||
| 841e0bd5e5 | |||
| 5c80bc12f0 | |||
| 60981e813b | |||
| 94eea040e2 | |||
| 6869160e97 | |||
| 4dc6ad9257 | |||
| b2c368e1cd | |||
| 1ea3b5440c | |||
| 9bbe311bdf | |||
| 5a338adf47 | |||
| 1d218d01b2 | |||
| ae6ba4ba44 | |||
| 90614fcb91 | |||
| 1bedd399d6 | |||
| 0bff610506 |
@@ -501,7 +501,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad
|
||||
/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend
|
||||
/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend
|
||||
/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad
|
||||
/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad
|
||||
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad
|
||||
|
||||
@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
annotationList, ok := annotations["list"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if annotationList, ok := annotations["list"].([]interface{}); ok {
|
||||
return len(annotationList)
|
||||
}
|
||||
if annotationList, ok := annotations["list"].([]map[string]interface{}); ok {
|
||||
return len(annotationList)
|
||||
}
|
||||
|
||||
return len(annotationList)
|
||||
return 0
|
||||
}
|
||||
|
||||
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
|
||||
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
links, ok := spec["links"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if links, ok := spec["links"].([]interface{}); ok {
|
||||
return len(links)
|
||||
}
|
||||
if links, ok := spec["links"].([]map[string]interface{}); ok {
|
||||
return len(links)
|
||||
}
|
||||
|
||||
return len(links)
|
||||
return 0
|
||||
}
|
||||
|
||||
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
|
||||
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
variableList, ok := templating["list"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if variableList, ok := templating["list"].([]interface{}); ok {
|
||||
return len(variableList)
|
||||
}
|
||||
if variableList, ok := templating["list"].([]map[string]interface{}); ok {
|
||||
return len(variableList)
|
||||
}
|
||||
|
||||
return len(variableList)
|
||||
return 0
|
||||
}
|
||||
|
||||
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard
|
||||
|
||||
Vendored
+15
-1
@@ -628,6 +628,20 @@
|
||||
}
|
||||
],
|
||||
"title": "Only nulls and no user set min \u0026 max",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "convertFieldType",
|
||||
"options": {
|
||||
"conversions": [
|
||||
{
|
||||
"destinationType": "number",
|
||||
"targetField": "A-series"
|
||||
}
|
||||
],
|
||||
"fields": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
@@ -1179,4 +1193,4 @@
|
||||
"title": "Panel Tests - Gauge",
|
||||
"uid": "_5rDmaQiz",
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+32
@@ -1760,6 +1760,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active gateways",
|
||||
"type": "radialbar"
|
||||
},
|
||||
@@ -1843,6 +1859,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active pods",
|
||||
"type": "radialbar"
|
||||
},
|
||||
|
||||
+1
@@ -485,6 +485,7 @@
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"displayName": "My gauge",
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -600,6 +600,20 @@
|
||||
"stringInput": "null,null"
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "convertFieldType",
|
||||
"options": {
|
||||
"fields": {},
|
||||
"conversions": [
|
||||
{
|
||||
"targetField": "A-series",
|
||||
"destinationType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Only nulls and no user set min & max",
|
||||
"type": "gauge"
|
||||
},
|
||||
|
||||
@@ -1718,6 +1718,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active gateways",
|
||||
"type": "radialbar"
|
||||
},
|
||||
@@ -1799,6 +1815,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active pods",
|
||||
"type": "radialbar"
|
||||
},
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"displayName": "My gauge",
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
// this test requires a larger viewport so all gauge panels load properly
|
||||
test.use({
|
||||
featureToggles: { newGauge: true },
|
||||
viewport: { width: 1280, height: 3000 },
|
||||
});
|
||||
|
||||
const OLD_GAUGES_DASHBOARD_UID = '_5rDmaQiz';
|
||||
const NEW_GAUGES_DASHBOARD_UID = 'panel-tests-gauge-new';
|
||||
|
||||
test.describe(
|
||||
'Gauge Panel',
|
||||
{
|
||||
tag: ['@panels', '@gauge'],
|
||||
},
|
||||
() => {
|
||||
test('successfully migrates all gauge panels', async ({ gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: OLD_GAUGES_DASHBOARD_UID });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.Panels.Visualization.Gauge.Container
|
||||
);
|
||||
await expect(gaugeElements).toHaveCount(16);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders new gauge panels', async ({ gotoDashboardPage, selectors }) => {
|
||||
// open Panel Tests - Gauge
|
||||
const dashboardPage = await gotoDashboardPage({ uid: NEW_GAUGES_DASHBOARD_UID });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.Panels.Visualization.Gauge.Container
|
||||
);
|
||||
await expect(gaugeElements).toHaveCount(32);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders sparklines in gauge panels', async ({ gotoDashboardPage, page }) => {
|
||||
await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '11' }),
|
||||
});
|
||||
|
||||
await expect(page.locator('.uplot')).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('"no data"', async ({ gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '36' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the gauge does not appear'
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
|
||||
'that the empty text appears'
|
||||
).toHaveText('No data');
|
||||
|
||||
// update the "No value" option and see if the panel updates
|
||||
const noValueOption = dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
|
||||
.locator('input');
|
||||
|
||||
await noValueOption.fill('My empty value');
|
||||
await noValueOption.blur();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the empty text shows up in an empty gauge'
|
||||
).toHaveText('My empty value');
|
||||
|
||||
// test the "no numeric fields" message on the next panel
|
||||
const dashboardPage2 = await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '37' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the gauge does not appear'
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
|
||||
'that the empty text appears'
|
||||
).toHaveText('Data is missing a number field');
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
// this test requires a larger viewport so all gauge panels load properly
|
||||
test.use({
|
||||
viewport: { width: 1280, height: 1080 },
|
||||
});
|
||||
|
||||
test.describe(
|
||||
'Gauge Panel',
|
||||
{
|
||||
tag: ['@various'],
|
||||
},
|
||||
() => {
|
||||
test('Gauge rendering e2e tests', async ({ gotoDashboardPage, selectors, page }) => {
|
||||
// open Panel Tests - Gauge
|
||||
const dashboardPage = await gotoDashboardPage({ uid: '_5rDmaQiz' });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = page.locator('.flot-base');
|
||||
await expect(gaugeElements).toHaveCount(16);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -535,6 +535,11 @@ export const versionedComponents = {
|
||||
'12.3.0': 'data-testid viz-tooltip-wrapper',
|
||||
},
|
||||
},
|
||||
Gauge: {
|
||||
Container: {
|
||||
'12.4.0': 'data-testid gauge container',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
VizLegend: {
|
||||
|
||||
Generated
+2
@@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions {
|
||||
showThresholdLabels: boolean;
|
||||
showThresholdMarkers: boolean;
|
||||
sparkline?: boolean;
|
||||
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
@@ -48,4 +49,5 @@ export const defaultOptions: Partial<Options> = {
|
||||
showThresholdLabels: false,
|
||||
showThresholdMarkers: true,
|
||||
sparkline: true,
|
||||
textMode: 'auto',
|
||||
};
|
||||
|
||||
@@ -248,15 +248,17 @@ export function PanelChrome({
|
||||
|
||||
const onContentPointerDown = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
// Ignore clicks inside buttons, links, canvas and svg elments
|
||||
// When selected, ignore clicks inside buttons, links, canvas and svg elments
|
||||
// This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click
|
||||
if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
|
||||
if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
|
||||
// Stop propagation otherwise row config editor will get selected
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(evt);
|
||||
},
|
||||
[onSelect]
|
||||
[isSelected, onSelect]
|
||||
);
|
||||
|
||||
const headerContent = (
|
||||
|
||||
@@ -32,24 +32,6 @@ const meta: Meta<StoryProps> = {
|
||||
controls: {
|
||||
exclude: ['theme', 'values', 'vizCount'],
|
||||
},
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
id: 'scrollable-region-focusable',
|
||||
selector: 'body',
|
||||
enabled: false,
|
||||
},
|
||||
// NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples.
|
||||
// The color-contrast in this component should be accessible!
|
||||
{
|
||||
id: 'color-contrast',
|
||||
selector: 'text',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
barWidthFactor: 0.2,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||
@@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.vizWrapper} style={{ width, height }}>
|
||||
<div
|
||||
data-testid={selectors.components.Panels.Visualization.Gauge.Container}
|
||||
className={styles.vizWrapper}
|
||||
style={{ width, height }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
|
||||
@@ -25,13 +25,14 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const CENTER_GLOW_OPACITY = 0.15;
|
||||
const CENTER_GLOW_OPACITY = 0.25;
|
||||
|
||||
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
||||
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
|
||||
return (
|
||||
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={transparentColor} />
|
||||
<stop offset="90%" stopColor={'#ffffff00'} />
|
||||
</radialGradient>
|
||||
);
|
||||
}
|
||||
@@ -44,13 +45,14 @@ export interface CenterGlowProps {
|
||||
|
||||
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
|
||||
const gradientId = `circle-glow-${gaugeId}`;
|
||||
const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color;
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<radialGradient id={gradientId} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={transparentColor} />
|
||||
<stop offset="90%" stopColor="#ffffff00" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
@@ -86,9 +88,9 @@ export function SpotlightGradient({
|
||||
|
||||
return (
|
||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
|
||||
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
|
||||
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
|
||||
<stop offset="0%" stopColor="#ffffff00" />
|
||||
<stop offset="95%" stopColor="#ffffff88" />
|
||||
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 {
|
||||
showHighlights?: boolean;
|
||||
}
|
||||
|
||||
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
|
||||
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
|
||||
|
||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
|
||||
if (warning) {
|
||||
return null;
|
||||
@@ -30,14 +31,4 @@ export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
});
|
||||
|
||||
SparklineFn.displayName = 'Sparkline';
|
||||
|
||||
// we converted to function component above, but some apps extend Sparkline, so we need
|
||||
// to keep exporting a class component until those apps are all rolled out.
|
||||
// see https://github.com/grafana/app-observability-plugin/pull/2079
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
|
||||
export class Sparkline extends React.PureComponent<SparklineProps> {
|
||||
render() {
|
||||
return <SparklineFn {...this.props} />;
|
||||
}
|
||||
}
|
||||
Sparkline.displayName = 'Sparkline';
|
||||
|
||||
@@ -167,7 +167,8 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
|
||||
|
||||
const repeaterStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
overflow: `${minVizWidth ? 'auto' : 'hidden'} ${minVizHeight ? 'auto' : 'hidden'}`,
|
||||
overflowX: `${minVizWidth ? 'auto' : 'hidden'}`,
|
||||
overflowY: `${minVizHeight ? 'auto' : 'hidden'}`,
|
||||
};
|
||||
|
||||
let vizHeight = height;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
// Optional extensions for an explicit datasource type
|
||||
// NOTE: the properties from this structure will be populated by reading an app-sdk manifest.json
|
||||
type DataSourceOpenAPIExtension struct {
|
||||
// When specified, this will replace the default spec
|
||||
DataSourceSpec *spec.Schema `json:"spec,omitempty"`
|
||||
|
||||
// Define which secure values are required
|
||||
SecureValues []SecureValueInfo `json:"secureValues"`
|
||||
|
||||
// Examples added to the POST command
|
||||
Examples map[string]*spec3.Example `json:"examples,omitempty"`
|
||||
|
||||
// Additional Schemas added to the response
|
||||
Schemas map[string]*spec.Schema `json:"schemas,omitempty"`
|
||||
|
||||
// TODO: define query types dynamically here (currently hardcoded)
|
||||
// Queries *queryV0.QueryTypeDefinitionList `json:"queries,omitempty"`
|
||||
|
||||
// Resource routes -- the paths exposed under:
|
||||
// {group}/{version}/namespaces/{ns}/datasource/{name}/resource/{route}
|
||||
Routes map[string]*spec3.Path `json:"routes,omitempty"`
|
||||
|
||||
// Proxy routes -- the paths exposed under:
|
||||
// {group}/{version}/namespaces/{ns}/datasource/{name}/proxy/{proxy}
|
||||
Proxy map[string]*spec3.Path `json:"proxy,omitempty"`
|
||||
}
|
||||
|
||||
type SecureValueInfo struct {
|
||||
// The key
|
||||
Key string `json:"string"`
|
||||
|
||||
// Description
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Required secure values
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// returns the current implementation version
|
||||
@@ -117,6 +118,23 @@ func (j *Json) Interface() any {
|
||||
return j.data
|
||||
}
|
||||
|
||||
// Check if the underlying data is empty
|
||||
func (j *Json) IsEmpty() bool {
|
||||
if j.data == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(j.data)
|
||||
switch v.Kind() {
|
||||
case reflect.Slice, reflect.Array, reflect.Map, reflect.String:
|
||||
if v.Len() == 0 {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Encode returns its marshaled data as `[]byte`
|
||||
func (j *Json) Encode() ([]byte, error) {
|
||||
return j.MarshalJSON()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSimplejson(t *testing.T) {
|
||||
@@ -272,3 +273,45 @@ func TestMustJson(t *testing.T) {
|
||||
MustJson([]byte(`{`))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmpty(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input any
|
||||
empty bool
|
||||
}{
|
||||
{
|
||||
name: "empty map (any)",
|
||||
input: map[string]any{},
|
||||
empty: true,
|
||||
}, {
|
||||
name: "empty map (string)",
|
||||
input: map[string]string{},
|
||||
empty: true,
|
||||
}, {
|
||||
name: "empty array (any)",
|
||||
input: []any{},
|
||||
empty: true,
|
||||
}, {
|
||||
name: "empty array (string)",
|
||||
input: []string{},
|
||||
empty: true,
|
||||
}, {
|
||||
name: "empty string",
|
||||
input: "",
|
||||
empty: true,
|
||||
}, {
|
||||
name: "non empty string",
|
||||
input: "hello",
|
||||
}, {
|
||||
name: "key value",
|
||||
input: map[string]any{"key": "value"},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
js := NewFromAny(tc.input)
|
||||
require.Equal(t, tc.empty, js.IsEmpty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,19 +55,33 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
|
||||
SetBasicAuthUser(ds.BasicAuthUser).
|
||||
SetWithCredentials(ds.WithCredentials).
|
||||
SetIsDefault(ds.IsDefault).
|
||||
SetReadOnly(ds.ReadOnly).
|
||||
SetJSONData(ds.JsonData)
|
||||
SetReadOnly(ds.ReadOnly)
|
||||
|
||||
if ds.JsonData != nil && !ds.JsonData.IsEmpty() {
|
||||
obj.Spec.SetJSONData(ds.JsonData.Interface())
|
||||
}
|
||||
|
||||
rv := int64(0)
|
||||
if !ds.Created.IsZero() {
|
||||
obj.CreationTimestamp = metav1.NewTime(ds.Created)
|
||||
rv = ds.Created.UnixMilli()
|
||||
}
|
||||
|
||||
// Only mark updated if the times have actually changed
|
||||
if !ds.Updated.IsZero() {
|
||||
obj.ResourceVersion = fmt.Sprintf("%d", ds.Updated.UnixMilli())
|
||||
obj.Annotations = map[string]string{
|
||||
utils.AnnoKeyUpdatedTimestamp: ds.Updated.Format(time.RFC3339),
|
||||
rv = ds.Updated.UnixMilli()
|
||||
delta := rv - obj.CreationTimestamp.UnixMilli()
|
||||
if delta > 1500 {
|
||||
obj.Annotations = map[string]string{
|
||||
utils.AnnoKeyUpdatedTimestamp: ds.Updated.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rv > 0 {
|
||||
obj.ResourceVersion = strconv.FormatInt(rv, 10)
|
||||
}
|
||||
|
||||
if ds.APIVersion != "" {
|
||||
obj.APIVersion = fmt.Sprintf("%s/%s", r.group, ds.APIVersion)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package hardcoded
|
||||
|
||||
import (
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
)
|
||||
|
||||
func TestdataOpenAPIExtension() (*datasourceV0.DataSourceOpenAPIExtension, error) {
|
||||
oas := &datasourceV0.DataSourceOpenAPIExtension{
|
||||
SecureValues: []datasourceV0.SecureValueInfo{ // empty
|
||||
// {
|
||||
// Key: "aaa",
|
||||
// Description: "describe aaa",
|
||||
// Required: true,
|
||||
// }, {
|
||||
// Key: "bbb",
|
||||
// Description: "describe bbb",
|
||||
// },
|
||||
},
|
||||
|
||||
Examples: map[string]*spec3.Example{
|
||||
"": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "Empty testdata",
|
||||
Value: map[string]any{
|
||||
"apiVersion": "testdata.datasource.grafana.app/v0alpha1",
|
||||
"kind": "DataSource",
|
||||
"metadata": map[string]any{
|
||||
"name": "my-testdata-datasource",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "My TestData Datasource",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"with-url": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "Testdata with URL (not used)",
|
||||
Value: map[string]any{
|
||||
"apiVersion": "testdata.datasource.grafana.app/v0alpha1",
|
||||
"kind": "DataSource",
|
||||
"metadata": map[string]any{
|
||||
"name": "testdata-with-url",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "TestData with URL",
|
||||
"url": "http://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Dummy spec
|
||||
p := &spec.Schema{} //SchemaProps: spec.SchemaProps{Type: []string{"object"}}}
|
||||
p.Description = "Test data does not require any explicit configuration"
|
||||
p.Required = []string{"title"}
|
||||
p.AdditionalProperties = &spec.SchemaOrBool{Allows: false}
|
||||
p.Properties = map[string]spec.Schema{
|
||||
"title": *spec.StringProperty().WithDescription("display name"),
|
||||
"url": *spec.StringProperty().WithDescription("not used"),
|
||||
}
|
||||
p.Example = map[string]any{
|
||||
"url": "http://xxxx",
|
||||
}
|
||||
oas.DataSourceSpec = p
|
||||
|
||||
// Resource routes
|
||||
// https://github.com/grafana/grafana/blob/main/pkg/tsdb/grafana-testdata-datasource/resource_handler.go#L20
|
||||
unstructured := spec.MapProperty(nil)
|
||||
unstructuredResponse := &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
Default: &spec3.Response{
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: unstructured,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
unstructuredRequest := &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: unstructured,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
oas.Routes = map[string]*spec3.Path{
|
||||
"": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "hello world",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
Default: &spec3.Response{
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"text/plain": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/scenarios": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "hello world",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/stream": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "Get streaming response",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "count",
|
||||
In: "query",
|
||||
Schema: spec.Int64Property(),
|
||||
Description: "number of points that will be returned",
|
||||
Example: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "start",
|
||||
In: "query",
|
||||
Schema: spec.Int64Property(),
|
||||
Description: "the start value",
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "flush",
|
||||
In: "query",
|
||||
Schema: spec.Int64Property(),
|
||||
Description: "How often the result is flushed (1-100%)",
|
||||
Example: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "speed",
|
||||
In: "query",
|
||||
Schema: spec.StringProperty(),
|
||||
Description: "the clock cycle",
|
||||
Example: "100ms",
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "format",
|
||||
In: "query",
|
||||
Schema: spec.StringProperty().WithEnum("json", "influx"),
|
||||
Description: "the response format",
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/boom": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "force a panic",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/test": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "Echo any request",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
RequestBody: unstructuredRequest,
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/test/json": {
|
||||
PathProps: spec3.PathProps{
|
||||
Summary: "Echo json request",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
RequestBody: unstructuredRequest,
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/sims": {
|
||||
PathProps: spec3.PathProps{
|
||||
Description: "Get list of simulations",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/sim/{key}": {
|
||||
PathProps: spec3.PathProps{
|
||||
Description: "Get list of simulations",
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "key",
|
||||
In: "path",
|
||||
Description: "simulation key (should include hz)",
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "key",
|
||||
In: "path",
|
||||
Description: "simulation key (should include hz)",
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestBody: unstructuredRequest,
|
||||
Responses: unstructuredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return oas, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package hardcoded
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
// "sigs.k8s.io/yaml" // uses the same structure as json!
|
||||
)
|
||||
|
||||
func TestSpec(t *testing.T) {
|
||||
info, err := TestdataOpenAPIExtension()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
|
||||
jj, err := json.MarshalIndent(info, "", " ")
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("%s\n", string(jj))
|
||||
|
||||
// jj, err = yaml.Marshal(info)
|
||||
// require.NoError(t, err)
|
||||
// fmt.Printf("%s\n", string(jj))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -9,11 +10,15 @@ import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -84,7 +89,16 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected a datasource object")
|
||||
}
|
||||
return s.datasources.CreateDataSource(ctx, ds)
|
||||
obj, err := s.datasources.CreateDataSource(ctx, ds)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, datasources.ErrDataSourceNameExists):
|
||||
return nil, apierrors.NewInvalid(s.resourceInfo.GroupVersionKind().GroupKind(), ds.Name, field.ErrorList{
|
||||
field.Invalid(field.NewPath("spec", "title"), ds.Spec.Title(), "a datasource with this title already exists")})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// Update implements rest.Updater.
|
||||
|
||||
@@ -2,17 +2,35 @@ package datasource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/query/queryschema"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
)
|
||||
|
||||
func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
// The plugin description
|
||||
oas.Info.Description = b.pluginJSON.Info.Description
|
||||
|
||||
// Add plugin information
|
||||
info := map[string]any{
|
||||
"plugin": b.pluginJSON.ID,
|
||||
}
|
||||
if b.pluginJSON.Info.Version != "" {
|
||||
info["version"] = b.pluginJSON.Info.Version
|
||||
}
|
||||
if b.pluginJSON.Info.Build.Time > 0 {
|
||||
info["build"] = b.pluginJSON.Info.Build.Time
|
||||
}
|
||||
oas.Info.AddExtension("plugin", info)
|
||||
|
||||
// The root api URL
|
||||
root := "/apis/" + b.datasourceResourceInfo.GroupVersion().String() + "/"
|
||||
|
||||
@@ -28,6 +46,17 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the operation ID for the query path
|
||||
query := oas.Paths.Paths[root+"namespaces/{namespace}/datasources/{name}/query"]
|
||||
if query != nil && query.Post != nil {
|
||||
query.Post.OperationId = "queryDataSource"
|
||||
for _, p := range query.Parameters {
|
||||
if p.Name == "name" {
|
||||
p.Description = "DataSource identifier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the resource routes -- explicit ones will be added if defined below
|
||||
prefix := root + "namespaces/{namespace}/datasources/{name}/resource"
|
||||
r := oas.Paths.Paths[prefix]
|
||||
@@ -52,7 +81,7 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
|
||||
|
||||
// Mark connections as deprecated
|
||||
delete(oas.Paths.Paths, root+"namespaces/{namespace}/connections/{name}")
|
||||
query := oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"]
|
||||
query = oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"]
|
||||
for query == nil || query.Post == nil {
|
||||
return nil, fmt.Errorf("missing temporary connection path")
|
||||
}
|
||||
@@ -70,5 +99,111 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
|
||||
},
|
||||
}
|
||||
|
||||
if b.schemaProvider == nil {
|
||||
return oas, nil
|
||||
}
|
||||
|
||||
custom, err := b.schemaProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applyCustomSchemas(root, ds, oas, custom)
|
||||
}
|
||||
|
||||
func applyCustomSchemas(root string, ds *spec.Schema, oas *spec3.OpenAPI, custom *datasourceV0.DataSourceOpenAPIExtension) (*spec3.OpenAPI, error) {
|
||||
if custom == nil {
|
||||
return oas, nil // nothing special
|
||||
}
|
||||
|
||||
// Add custom schemas
|
||||
maps.Copy(oas.Components.Schemas, custom.Schemas)
|
||||
|
||||
// Replace the generic DataSourceSpec with the explicit one
|
||||
if custom.DataSourceSpec != nil {
|
||||
oas.Components.Schemas["DataSourceSpec"] = custom.DataSourceSpec
|
||||
ds.Properties["spec"] = spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Ref: spec.MustCreateRef("#/components/schemas/DataSourceSpec"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if len(custom.SecureValues) > 0 {
|
||||
example := common.InlineSecureValues{}
|
||||
ref := spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue")
|
||||
secure := &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Properties: make(map[string]spec.Schema),
|
||||
AdditionalProperties: &spec.SchemaOrBool{Allows: false},
|
||||
}}
|
||||
secure.Description = "custom secure value definition"
|
||||
|
||||
for _, v := range custom.SecureValues {
|
||||
secure.Properties[v.Key] = spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: v.Description,
|
||||
Ref: ref,
|
||||
},
|
||||
}
|
||||
if v.Required {
|
||||
secure.Required = append(secure.Required, v.Key)
|
||||
example[v.Key] = common.InlineSecureValue{Create: "***"}
|
||||
}
|
||||
}
|
||||
|
||||
if len(example) > 0 {
|
||||
secure.Example = example
|
||||
}
|
||||
|
||||
// Link the explicit secure values in the resource
|
||||
oas.Components.Schemas["SecureValues"] = secure
|
||||
ds.Properties["secure"] = spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Ref: spec.MustCreateRef("#/components/schemas/SecureValues"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Add examples to the POST request
|
||||
if len(custom.Examples) > 0 {
|
||||
ds := oas.Paths.Paths[root+"namespaces/{namespace}/datasources"]
|
||||
if ds != nil && ds.Post != nil {
|
||||
for _, c := range ds.Post.RequestBody.Content {
|
||||
c.Examples = custom.Examples
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(custom.Routes) > 0 {
|
||||
ds := oas.Paths.Paths[root+"namespaces/{namespace}/datasources/{name}"]
|
||||
if ds == nil || len(ds.Parameters) < 2 {
|
||||
return nil, fmt.Errorf("missing Parameters")
|
||||
}
|
||||
|
||||
prefix := root + "namespaces/{namespace}/datasources/{name}/resource"
|
||||
for k := range oas.Paths.Paths {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(oas.Paths.Paths, k)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range custom.Routes {
|
||||
if k != "" && !strings.HasPrefix(k, "/") {
|
||||
return nil, fmt.Errorf("path must have slash prefix")
|
||||
}
|
||||
v.Parameters = append(v.Parameters, ds.Parameters[0:2]...)
|
||||
for m, op := range builder.GetPathOperations(v) {
|
||||
if op.Extensions == nil {
|
||||
op.Extensions = make(spec.Extensions)
|
||||
}
|
||||
if !slices.Contains(op.Tags, "Route") {
|
||||
op.Tags = append(op.Tags, "Route") // Custom resource?
|
||||
}
|
||||
tmp := strings.ReplaceAll(strings.ReplaceAll(k, "{", ""), "}", "")
|
||||
op.OperationId = fmt.Sprintf("%s_route%s", strings.ToLower(m), strings.ReplaceAll(tmp, "/", "_"))
|
||||
}
|
||||
oas.Paths.Paths[prefix+k] = v
|
||||
}
|
||||
}
|
||||
return oas, nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource/hardcoded"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/query/queryschema"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
@@ -39,14 +40,16 @@ var (
|
||||
// DataSourceAPIBuilder is used just so wire has something unique to return
|
||||
type DataSourceAPIBuilder struct {
|
||||
datasourceResourceInfo utils.ResourceInfo
|
||||
pluginJSON plugins.JSONData
|
||||
client PluginClient // will only ever be called with the same plugin id!
|
||||
datasources PluginDatasourceProvider
|
||||
contextProvider PluginContextWrapper
|
||||
accessControl accesscontrol.AccessControl
|
||||
queryTypes *queryV0.QueryTypeDefinitionList
|
||||
configCrudUseNewApis bool
|
||||
dataSourceCRUDMetric *prometheus.HistogramVec
|
||||
|
||||
pluginJSON plugins.JSONData
|
||||
client PluginClient // will only ever be called with the same plugin id!
|
||||
datasources PluginDatasourceProvider
|
||||
contextProvider PluginContextWrapper
|
||||
accessControl accesscontrol.AccessControl
|
||||
queryTypes *queryV0.QueryTypeDefinitionList
|
||||
schemaProvider func() (*datasourceV0.DataSourceOpenAPIExtension, error)
|
||||
configCrudUseNewApis bool
|
||||
dataSourceCRUDMetric *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -59,8 +62,17 @@ func RegisterAPIService(
|
||||
reg prometheus.Registerer,
|
||||
pluginSources sources.Registry,
|
||||
) (*DataSourceAPIBuilder, error) {
|
||||
//nolint:staticcheck
|
||||
useQueryTypes := features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes)
|
||||
|
||||
//nolint:staticcheck
|
||||
configCrudUseNewApis := features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections)
|
||||
|
||||
//nolint:staticcheck
|
||||
isExperimental := features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
if !configCrudUseNewApis && !isExperimental {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -82,28 +94,33 @@ func RegisterAPIService(
|
||||
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
|
||||
}
|
||||
|
||||
for _, pluginJSON := range pluginJSONs {
|
||||
client, ok := pluginClient.(PluginClient)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
|
||||
}
|
||||
// For the ST runner, the client can talk to any plugin
|
||||
client, ok := pluginClient.(PluginClient)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
|
||||
}
|
||||
|
||||
for _, pluginJSON := range pluginJSONs {
|
||||
builder, err = NewDataSourceAPIBuilder(
|
||||
pluginJSON,
|
||||
client,
|
||||
datasources.GetDatasourceProvider(pluginJSON),
|
||||
contextProvider,
|
||||
accessControl,
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections),
|
||||
useQueryTypes, // Exposes the query type OpenAPI schema
|
||||
configCrudUseNewApis, // Enables the new connections-based datasource config CRUD APIs
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
|
||||
|
||||
// Hardcoded schemas for testdata
|
||||
// NOTE: this will be driven by the pluginJSON/manifest soon
|
||||
if pluginJSON.ID == "grafana-testdata-datasource" {
|
||||
builder.schemaProvider = hardcoded.TestdataOpenAPIExtension
|
||||
}
|
||||
|
||||
apiRegistrar.RegisterAPI(builder)
|
||||
}
|
||||
return builder, nil // only used for wire
|
||||
@@ -247,6 +264,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Read-only access to datasource connection info
|
||||
storage[ds.StoragePath()] = &connectionAccess{
|
||||
datasources: b.datasources,
|
||||
resourceInfo: ds,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "unique-identifier",
|
||||
"namespace": "org-0",
|
||||
"uid": "YpaSG5GQAdxtLZtF6BqQWCeYXOhbVi5C4Cg4oILnJC0X",
|
||||
"resourceVersion": "1015203600000",
|
||||
"generation": 8,
|
||||
"creationTimestamp": "2002-03-04T01:00:00Z",
|
||||
"labels": {
|
||||
@@ -10,7 +11,6 @@
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"jsonData": null,
|
||||
"title": "Display name"
|
||||
}
|
||||
}
|
||||
-1
@@ -5,7 +5,6 @@
|
||||
"uid": "boDNh7zU3nXj46rOXIJI7r44qaxjs8yy9I9dOj1MyBoX"
|
||||
},
|
||||
"spec": {
|
||||
"jsonData": null,
|
||||
"title": "Hello testdata"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -232,11 +233,9 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
|
||||
parent := copy.Paths.Paths[path[:idx+6]]
|
||||
if parent != nil && parent.Get != nil {
|
||||
for _, op := range GetPathOperations(spec) {
|
||||
if op != nil && op.Extensions != nil {
|
||||
action, ok := op.Extensions.GetString("x-kubernetes-action")
|
||||
if ok && action == "connect" {
|
||||
op.Tags = parent.Get.Tags
|
||||
}
|
||||
action, ok := op.Extensions.GetString("x-kubernetes-action")
|
||||
if ok && action == "connect" {
|
||||
op.Tags = parent.Get.Tags
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,15 +248,32 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
|
||||
}
|
||||
}
|
||||
|
||||
func GetPathOperations(path *spec3.Path) []*spec3.Operation {
|
||||
return []*spec3.Operation{
|
||||
path.Get,
|
||||
path.Head,
|
||||
path.Delete,
|
||||
path.Patch,
|
||||
path.Post,
|
||||
path.Put,
|
||||
path.Trace,
|
||||
path.Options,
|
||||
// GetPathOperations returns the set of non-nil operations defined on a path
|
||||
func GetPathOperations(path *spec3.Path) map[string]*spec3.Operation {
|
||||
ops := make(map[string]*spec3.Operation)
|
||||
if path.Get != nil {
|
||||
ops[http.MethodGet] = path.Get
|
||||
}
|
||||
if path.Head != nil {
|
||||
ops[http.MethodHead] = path.Head
|
||||
}
|
||||
if path.Delete != nil {
|
||||
ops[http.MethodDelete] = path.Delete
|
||||
}
|
||||
if path.Post != nil {
|
||||
ops[http.MethodPost] = path.Post
|
||||
}
|
||||
if path.Put != nil {
|
||||
ops[http.MethodPut] = path.Put
|
||||
}
|
||||
if path.Patch != nil {
|
||||
ops[http.MethodPatch] = path.Patch
|
||||
}
|
||||
if path.Trace != nil {
|
||||
ops[http.MethodTrace] = path.Trace
|
||||
}
|
||||
if path.Options != nil {
|
||||
ops[http.MethodOptions] = path.Options
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
)
|
||||
|
||||
func TestOpenAPI_GetPathOperations(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input *spec3.Path
|
||||
expect []string // the methods we should see
|
||||
exclude []string // the methods we should never see
|
||||
}{
|
||||
{
|
||||
name: "some operations",
|
||||
input: &spec3.Path{
|
||||
PathProps: spec3.PathProps{
|
||||
Get: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "get"}},
|
||||
Post: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "post"}},
|
||||
Delete: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "delete"}},
|
||||
},
|
||||
},
|
||||
expect: []string{"GET", "POST", "DELETE"},
|
||||
exclude: []string{"PUT", "PATCH", "OPTIONS", "HEAD", "TRACE"},
|
||||
},
|
||||
{
|
||||
name: "all operations",
|
||||
input: &spec3.Path{
|
||||
PathProps: spec3.PathProps{
|
||||
Get: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "get"}},
|
||||
Post: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "post"}},
|
||||
Delete: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "delete"}},
|
||||
Put: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "put"}},
|
||||
Patch: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "patch"}},
|
||||
Options: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "options"}},
|
||||
Head: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "head"}},
|
||||
Trace: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "trace"}},
|
||||
},
|
||||
},
|
||||
expect: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS", "HEAD", "TRACE"},
|
||||
exclude: []string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
expect := make(map[string]bool)
|
||||
for _, k := range tt.expect {
|
||||
expect[k] = true
|
||||
}
|
||||
|
||||
for k, op := range GetPathOperations(tt.input) {
|
||||
require.NotNil(t, op)
|
||||
require.Equal(t, strings.ToLower(k), op.Summary)
|
||||
|
||||
if !expect[k] {
|
||||
if slices.Contains(tt.expect, k) {
|
||||
require.Fail(t, "method returned multiple times", k)
|
||||
} else {
|
||||
require.Fail(t, "unexpected method", k)
|
||||
}
|
||||
}
|
||||
delete(expect, k)
|
||||
require.NotContains(t, tt.exclude, k, "exclude")
|
||||
}
|
||||
|
||||
if len(expect) > 0 {
|
||||
require.Fail(t, "missing expected method", expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -873,10 +873,19 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
|
||||
require.Failf(t, "Not OK", "Code[%d] %s", rsp.Response.StatusCode, string(rsp.Body))
|
||||
}
|
||||
|
||||
var prettyJSON bytes.Buffer
|
||||
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
|
||||
schema := map[string]any{}
|
||||
err := json.Unmarshal(rsp.Body, &schema)
|
||||
require.NoError(t, err)
|
||||
info, found, err := unstructured.NestedMap(schema, "info", "plugin")
|
||||
require.NoError(t, err)
|
||||
if found {
|
||||
delete(info, "version") // the version is unstable in test environments
|
||||
err = unstructured.SetNestedMap(schema, info, "info", "plugin")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
pretty, err := json.MarshalIndent(schema, "", " ")
|
||||
require.NoError(t, err)
|
||||
pretty := prettyJSON.String()
|
||||
|
||||
write := false
|
||||
fpath := filepath.Join(dir, fmt.Sprintf("%s-%s.json", gv.Group, gv.Version))
|
||||
@@ -885,7 +894,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
|
||||
// We can ignore the gosec G304 warning since this is a test and the function is only called with explicit paths
|
||||
body, err := os.ReadFile(fpath)
|
||||
if err == nil {
|
||||
if !assert.JSONEq(t, string(body), pretty) {
|
||||
if !assert.JSONEq(t, string(body), string(pretty)) {
|
||||
t.Logf("openapi spec has changed: %s", path)
|
||||
t.Fail()
|
||||
write = true
|
||||
@@ -896,7 +905,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
|
||||
}
|
||||
|
||||
if write {
|
||||
e2 := os.WriteFile(fpath, []byte(pretty), 0o644)
|
||||
e2 := os.WriteFile(fpath, pretty, 0o644)
|
||||
if e2 != nil {
|
||||
t.Errorf("error writing file: %s", e2.Error())
|
||||
}
|
||||
|
||||
+466
-22
@@ -2,7 +2,10 @@
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"description": "Generates test data in different forms",
|
||||
"title": "testdata.datasource.grafana.app/v0alpha1"
|
||||
"title": "testdata.datasource.grafana.app/v0alpha1",
|
||||
"plugin": {
|
||||
"plugin": "grafana-testdata-datasource"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/": {
|
||||
@@ -377,7 +380,7 @@
|
||||
"DataSource"
|
||||
],
|
||||
"description": "Query the TestData datasources",
|
||||
"operationId": "createDataSourceQuery",
|
||||
"operationId": "queryDataSource",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -410,7 +413,7 @@
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the QueryDataResponse",
|
||||
"description": "DataSource identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
@@ -430,36 +433,462 @@
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
|
||||
"summary": "hello world",
|
||||
"get": {
|
||||
"tags": [
|
||||
"DataSource"
|
||||
"Route"
|
||||
],
|
||||
"description": "Get resources in the datasource plugin. NOTE, additional routes may exist, but are not exposed via OpenAPI",
|
||||
"operationId": "getDataSourceResource",
|
||||
"operationId": "get_route",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"default": {
|
||||
"content": {
|
||||
"*/*": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-kubernetes-action": "connect",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "testdata.datasource.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "Status"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the Status",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/boom": {
|
||||
"summary": "force a panic",
|
||||
"get": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "get_route_boom",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "post_route_boom",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/scenarios": {
|
||||
"summary": "hello world",
|
||||
"get": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "get_route_scenarios",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/sim/{key}": {
|
||||
"description": "Get list of simulations",
|
||||
"get": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "get_route_sim_key",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"description": "simulation key (should include hz)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "post_route_sim_key",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"description": "simulation key (should include hz)"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/sims": {
|
||||
"description": "Get list of simulations",
|
||||
"get": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "get_route_sims",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/stream": {
|
||||
"summary": "Get streaming response",
|
||||
"get": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "get_route_stream",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "count",
|
||||
"in": "query",
|
||||
"description": "number of points that will be returned",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"example": 10
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"description": "the start value",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "flush",
|
||||
"in": "query",
|
||||
"description": "How often the result is flushed (1-100%)",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"example": 100
|
||||
},
|
||||
{
|
||||
"name": "speed",
|
||||
"in": "query",
|
||||
"description": "the clock cycle",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "100ms"
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "the response format",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"influx"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/test": {
|
||||
"summary": "Echo any request",
|
||||
"post": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "post_route_test",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "object name and auth scope, such as for teams and projects",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/test/json": {
|
||||
"summary": "Echo json request",
|
||||
"post": {
|
||||
"tags": [
|
||||
"Route"
|
||||
],
|
||||
"operationId": "post_route_test_json",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "name of the DataSource",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
@@ -530,6 +959,26 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"DataSourceSpec": {
|
||||
"description": "Test data does not require any explicit configuration",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "not used",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"example": {
|
||||
"url": "http://xxxx"
|
||||
}
|
||||
},
|
||||
"QueryRequestSchema": {
|
||||
"description": "Schema for a set of queries sent to the query method",
|
||||
"type": "object",
|
||||
@@ -650,12 +1099,7 @@
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "DataSource configuration -- these properties are all visible to anyone able to query the data source from their browser",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.datasource.v0alpha1.UnstructuredSpec"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/DataSourceSpec"
|
||||
}
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
|
||||
@@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques
|
||||
filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS)
|
||||
filters.AddQueryStringFilter(q.RawQuery, true)
|
||||
|
||||
if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" {
|
||||
if q.EditorType != nil && *q.EditorType == "code" {
|
||||
cfg := backend.GrafanaConfigFromContext(e.ctx)
|
||||
if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") {
|
||||
return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type"))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
// isQueryWithError validates the query and returns an error if invalid
|
||||
func isQueryWithError(query *Query) error {
|
||||
// Skip validation for raw DSL queries because no easy way to see it is valid without just running it
|
||||
if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" {
|
||||
if query.EditorType != nil && *query.EditorType == "code" {
|
||||
return nil
|
||||
}
|
||||
if len(query.BucketAggs) == 0 {
|
||||
|
||||
+27
-20
@@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Box, Button, Grid, Stack, Text } from '@grafana/ui';
|
||||
import { Box, Button, Stack, Text } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard';
|
||||
@@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [
|
||||
DataTransformerID.filterByValue,
|
||||
];
|
||||
|
||||
const GRID_COLUMNS_WITH_SQL = 5;
|
||||
const GRID_COLUMNS_WITHOUT_SQL = 4;
|
||||
|
||||
export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) {
|
||||
return (
|
||||
<Box alignItems="center" padding={4}>
|
||||
@@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
|
||||
};
|
||||
|
||||
const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions;
|
||||
const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL;
|
||||
|
||||
return (
|
||||
<Box alignItems="center" padding={4}>
|
||||
<Stack direction="column" alignItems="center" gap={4}>
|
||||
<Box padding={2}>
|
||||
<Stack direction="column" alignItems="start" gap={2}>
|
||||
<Stack direction="column" alignItems="start" gap={1}>
|
||||
<Text element="h3" textAlignment="start">
|
||||
<Trans i18nKey="transformations.empty.add-transformation-header">Add a Transformation</Trans>
|
||||
</Text>
|
||||
<Text element="p" textAlignment="start" color="secondary">
|
||||
<Trans i18nKey="transformations.empty.add-transformation-body">
|
||||
Transformations allow data to be changed in various ways before your visualization is shown.
|
||||
<br />
|
||||
This includes joining data together, renaming fields, making calculations, formatting data for display,
|
||||
and more.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
{(hasAddTransformation || hasGoToQueries) && (
|
||||
<Grid columns={gridColumns} gap={1}>
|
||||
<Stack direction="row" gap={1} wrap>
|
||||
{showSqlCard && (
|
||||
<SqlExpressionCard
|
||||
name={t('dashboard-scene.empty-transformations-message.sql-name', 'Transform with SQL')}
|
||||
@@ -125,19 +134,17 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
|
||||
data={props.data}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" gap={2}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleShowMoreClick}
|
||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleShowMoreClick}
|
||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -112,6 +112,37 @@ describe('PanelEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entering panel edit', () => {
|
||||
it('should clear edit pane selection', () => {
|
||||
pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
|
||||
|
||||
const panel = new VizPanel({
|
||||
key: 'panel-1',
|
||||
pluginId: 'text',
|
||||
title: 'original title',
|
||||
});
|
||||
const gridItem = new DashboardGridItem({ body: panel });
|
||||
const panelEditor = buildPanelEditScene(panel);
|
||||
const dashboard = new DashboardScene({
|
||||
editPanel: panelEditor,
|
||||
isEditing: true,
|
||||
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
children: [gridItem],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true });
|
||||
expect(dashboard.state.editPane.getSelection()).toBe(panel);
|
||||
|
||||
deactivate = activateFullSceneTree(dashboard);
|
||||
|
||||
expect(dashboard.state.editPane.getSelection()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When discarding', () => {
|
||||
it('should discard changes revert all changes', async () => {
|
||||
const { panelEditor, panel, dashboard } = await setup();
|
||||
|
||||
@@ -84,6 +84,11 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
|
||||
private _activationHandler() {
|
||||
const panel = this.state.panelRef.resolve();
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
|
||||
// Clear any panel selection when entering panel edit mode.
|
||||
// Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode
|
||||
dashboard.state.editPane.clearSelection();
|
||||
|
||||
if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
|
||||
if (config.featureToggles.newVizSuggestions) {
|
||||
|
||||
@@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
|
||||
}, [layoutManager]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
|
||||
<div
|
||||
className={cx(styles.addAction, 'dashboard-canvas-add-button')}
|
||||
onPointerUp={(evt) => evt.stopPropagation()}
|
||||
onPointerDown={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
fill="text"
|
||||
@@ -189,7 +193,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: theme.spacing(5),
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: theme.transitions.create('opacity'),
|
||||
|
||||
+8
-54
@@ -1,7 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Card, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Card, useStyles2 } from '@grafana/ui';
|
||||
import { getCardStyles } from './getCardStyles';
|
||||
|
||||
export interface SqlExpressionCardProps {
|
||||
name: string;
|
||||
@@ -12,60 +11,15 @@ export interface SqlExpressionCardProps {
|
||||
}
|
||||
|
||||
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
|
||||
const styles = useStyles2(getSqlExpressionCardStyles);
|
||||
const styles = useStyles2(getCardStyles);
|
||||
|
||||
return (
|
||||
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
|
||||
<Card.Heading className={styles.heading}>
|
||||
<div className={styles.titleRow}>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</Card.Heading>
|
||||
<Card.Description className={styles.description}>
|
||||
<span>{description}</span>
|
||||
{imageUrl && (
|
||||
<span>
|
||||
<img className={styles.image} src={imageUrl} alt={name} />
|
||||
</span>
|
||||
)}
|
||||
<Card className={styles.baseCard} data-testid={testId} onClick={onClick} noMargin>
|
||||
<Card.Heading>{name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<Text variant="bodySmall">{description}</Text>
|
||||
{imageUrl && <img className={styles.image} src={imageUrl} alt={name} />}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
card: css({
|
||||
gridTemplateRows: 'min-content 0 1fr 0',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
heading: css({
|
||||
fontWeight: 400,
|
||||
'> button': {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
titleRow: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
}),
|
||||
description: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+22
-84
@@ -1,35 +1,38 @@
|
||||
import { cx, css } from '@emotion/css';
|
||||
import { cx } from '@emotion/css';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
GrafanaTheme2,
|
||||
TransformerRegistryItem,
|
||||
TransformationApplicabilityLevels,
|
||||
standardTransformersRegistry,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
||||
|
||||
import { getCardStyles } from './getCardStyles';
|
||||
|
||||
export interface TransformationCardProps {
|
||||
transform: TransformerRegistryItem;
|
||||
data?: DataFrame[];
|
||||
fullWidth?: boolean;
|
||||
onClick: (id: string) => void;
|
||||
showIllustrations?: boolean;
|
||||
data?: DataFrame[];
|
||||
showPluginState?: boolean;
|
||||
showTags?: boolean;
|
||||
transform: TransformerRegistryItem;
|
||||
}
|
||||
|
||||
export function TransformationCard({
|
||||
transform,
|
||||
showIllustrations,
|
||||
onClick,
|
||||
data = [],
|
||||
fullWidth = false,
|
||||
onClick,
|
||||
showIllustrations,
|
||||
showPluginState = true,
|
||||
showTags = true,
|
||||
transform,
|
||||
}: TransformationCardProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getTransformationCardStyles);
|
||||
const styles = useStyles2(getCardStyles, fullWidth);
|
||||
|
||||
// Check to see if the transform is applicable to the given data
|
||||
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
|
||||
@@ -47,7 +50,7 @@ export function TransformationCard({
|
||||
}
|
||||
}
|
||||
|
||||
const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard;
|
||||
const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable });
|
||||
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
|
||||
const description = standardTransformersRegistry.getIfExists(transform.id)?.description;
|
||||
|
||||
@@ -58,15 +61,11 @@ export function TransformationCard({
|
||||
onClick={() => onClick(transform.id)}
|
||||
noMargin
|
||||
>
|
||||
<Card.Heading className={styles.heading}>
|
||||
<div className={styles.titleRow}>
|
||||
<span>{transform.name}</span>
|
||||
{showPluginState && (
|
||||
<span className={styles.pluginStateInfoWrapper}>
|
||||
<PluginStateInfo state={transform.state} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Card.Heading>
|
||||
<Stack alignItems="center" justifyContent="space-between">
|
||||
{transform.name}
|
||||
{showPluginState && <PluginStateInfo state={transform.state} />}
|
||||
</Stack>
|
||||
{showTags && transform.tags && transform.tags.size > 0 && (
|
||||
<div className={styles.tagsWrapper}>
|
||||
{Array.from(transform.tags).map((tag) => (
|
||||
@@ -75,74 +74,13 @@ export function TransformationCard({
|
||||
</div>
|
||||
)}
|
||||
</Card.Heading>
|
||||
<Card.Description className={styles.description}>
|
||||
<span>{description}</span>
|
||||
{showIllustrations && imageUrl && (
|
||||
<span>
|
||||
<img className={styles.image} src={imageUrl} alt={transform.name} />
|
||||
</span>
|
||||
)}
|
||||
<Card.Description>
|
||||
<Text variant="bodySmall">{description || ''}</Text>
|
||||
{showIllustrations && imageUrl && <img className={styles.image} src={imageUrl} alt={transform.name} />}
|
||||
{!isApplicable && applicabilityDescription !== null && (
|
||||
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
|
||||
<IconButton className={styles.applicableInfoButton} name="info-circle" tooltip={applicabilityDescription} />
|
||||
)}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getTransformationCardStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
heading: css({
|
||||
fontWeight: 400,
|
||||
'> button': {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
titleRow: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
}),
|
||||
description: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
cardDisabled: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
img: {
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.33,
|
||||
},
|
||||
}),
|
||||
cardApplicableInfo: css({
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
}),
|
||||
newCard: css({
|
||||
gridTemplateRows: 'min-content 0 1fr 0',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
pluginStateInfoWrapper: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
tagsWrapper: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+5
-4
@@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data
|
||||
<Grid columns={3} gap={1}>
|
||||
{transformations.map((transform) => (
|
||||
<TransformationCard
|
||||
key={transform.id}
|
||||
transform={transform}
|
||||
showIllustrations={showIllustrations}
|
||||
onClick={onClick}
|
||||
data={data}
|
||||
fullWidth
|
||||
key={transform.id}
|
||||
onClick={onClick}
|
||||
showIllustrations={showIllustrations}
|
||||
transform={transform}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({
|
||||
baseCard: css({
|
||||
maxWidth: fullWidth ? 'none' : '200px',
|
||||
width: fullWidth ? '100%' : 'auto',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
cardDisabled: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
img: {
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.33,
|
||||
},
|
||||
}),
|
||||
applicableInfoButton: css({
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
}),
|
||||
tagsWrapper: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(0.5),
|
||||
marginTop: theme.spacing(0.5),
|
||||
}),
|
||||
});
|
||||
+24
-1
@@ -7,7 +7,7 @@ import {
|
||||
import { defaultBucketAgg } from '../../../../queryDef';
|
||||
import { reducerTester } from '../../../reducerTester';
|
||||
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { bucketAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => {
|
||||
.thenStateShouldEqual([bucketAgg]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should reset bucket aggregations to default when switching editor types', () => {
|
||||
const defaultTimeField = '@timestamp';
|
||||
const initialState: BucketAggregation[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'date_histogram',
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'terms',
|
||||
field: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['bucketAggs']>()
|
||||
.givenReducer(createReducer(defaultTimeField), initialState)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
|
||||
import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { bucketAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -87,6 +87,11 @@ export const createReducer =
|
||||
return state;
|
||||
}
|
||||
|
||||
if (changeEditorTypeAndResetQuery.match(action)) {
|
||||
// Returns the default bucket agg. We will always want to set the default when switching types
|
||||
return [{ ...defaultBucketAgg('2'), field: defaultTimeField }];
|
||||
}
|
||||
|
||||
if (changeBucketAggregationSetting.match(action)) {
|
||||
return state!.map((bucketAgg) => {
|
||||
if (bucketAgg.id !== action.payload.bucketAgg.id) {
|
||||
|
||||
+23
-1
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
import { defaultMetricAgg } from '../../../../queryDef';
|
||||
import { reducerTester } from '../../../reducerTester';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { metricAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => {
|
||||
.whenActionIsDispatched(initQuery())
|
||||
.thenStateShouldEqual([defaultMetricAgg('1')]);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should reset to single default metric when switching to code editor', () => {
|
||||
const initialState: MetricAggregation[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'avg',
|
||||
field: 'value',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'max',
|
||||
field: 'value',
|
||||
},
|
||||
];
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['metrics']>()
|
||||
.givenReducer(reducer, initialState)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual([defaultMetricAgg('1')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc
|
||||
|
||||
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
|
||||
import { getChildren, metricAggregationConfig } from '../utils';
|
||||
|
||||
@@ -65,6 +65,11 @@ export const reducer = (
|
||||
});
|
||||
}
|
||||
|
||||
if (changeEditorTypeAndResetQuery.match(action)) {
|
||||
// Reset to default metric when switching to editor types
|
||||
return [defaultMetricAgg('1')];
|
||||
}
|
||||
|
||||
if (changeMetricField.match(action)) {
|
||||
return state!.map((metric) => {
|
||||
if (metric.id !== action.payload.id) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { reducerTester } from '../reducerTester';
|
||||
|
||||
import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state';
|
||||
import {
|
||||
aliasPatternReducer,
|
||||
changeAliasPattern,
|
||||
changeEditorTypeAndResetQuery,
|
||||
changeQuery,
|
||||
initQuery,
|
||||
queryReducer,
|
||||
rawDSLQueryReducer,
|
||||
} from './state';
|
||||
|
||||
describe('Query Reducer', () => {
|
||||
describe('On Init', () => {
|
||||
@@ -42,6 +50,17 @@ describe('Query Reducer', () => {
|
||||
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
|
||||
.thenStateShouldEqual(initialState);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should clear query when switching editor types', () => {
|
||||
const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['query']>()
|
||||
.givenReducer(queryReducer, initialQuery)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alias Pattern Reducer', () => {
|
||||
@@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => {
|
||||
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
|
||||
.thenStateShouldEqual(initialState);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should clear alias when switching editor types', () => {
|
||||
const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['alias']>()
|
||||
.givenReducer(aliasPatternReducer, initialAlias)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Raw DSL Query Reducer', () => {
|
||||
it('Should clear raw DSL query when switching editor types', () => {
|
||||
const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['rawDSLQuery']>()
|
||||
.givenReducer(rawDSLQueryReducer, initialRawQuery)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('builder'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,10 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al
|
||||
return action.payload;
|
||||
}
|
||||
|
||||
if (changeEditorTypeAndResetQuery.match(action)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (initQuery.match(action)) {
|
||||
return prevAliasPattern || '';
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function RadialBarPanel({
|
||||
nameManualFontSize={options.text?.titleSize}
|
||||
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
|
||||
onClick={menuProps.openMenu}
|
||||
textMode={options.textMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -65,9 +66,7 @@ export function RadialBarPanel({
|
||||
if (hasLinks && getLinks) {
|
||||
return (
|
||||
<DataLinksContextMenu links={getLinks} style={{ flexGrow: 1 }}>
|
||||
{(api) => {
|
||||
return renderComponent(valueProps, api);
|
||||
}}
|
||||
{(api) => renderComponent(valueProps, api)}
|
||||
</DataLinksContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-1
@@ -1,7 +1,7 @@
|
||||
import { PanelModel } from '@grafana/data';
|
||||
import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen';
|
||||
|
||||
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
|
||||
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './migrations';
|
||||
|
||||
describe('Gauge Panel Migrations', () => {
|
||||
it('from old gauge', () => {
|
||||
@@ -32,6 +32,51 @@ describe('Gauge Panel Migrations', () => {
|
||||
expect(result.sparkline).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
textMode: 'value_and_name',
|
||||
displayName: 'My gauge',
|
||||
},
|
||||
{
|
||||
textMode: undefined,
|
||||
displayName: '',
|
||||
},
|
||||
{
|
||||
textMode: undefined,
|
||||
displayName: null,
|
||||
},
|
||||
{
|
||||
textMode: undefined,
|
||||
displayName: undefined,
|
||||
},
|
||||
])('sets the the text mode to "$textMode" for "$displayName"', ({ textMode, displayName }) => {
|
||||
const panel = {
|
||||
id: 2,
|
||||
options: {
|
||||
displayName,
|
||||
reduceOptions: {
|
||||
calcs: ['lastNotNull'],
|
||||
},
|
||||
showThresholdLabels: false,
|
||||
showThresholdMarkers: true,
|
||||
},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: {
|
||||
mode: FieldColorModeId.Fixed,
|
||||
fixedColor: 'blue',
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
pluginVersion: '12.3.0',
|
||||
type: 'gauge',
|
||||
} as Omit<PanelModel, 'fieldConfig'>;
|
||||
|
||||
const result = gaugePanelMigrationHandler(panel as PanelModel);
|
||||
expect(result.textMode).toEqual(textMode);
|
||||
});
|
||||
|
||||
it('does not overwrite new gauge', () => {
|
||||
const panel = {
|
||||
id: 2,
|
||||
+5
@@ -21,6 +21,11 @@ export function gaugePanelMigrationHandler(panel: PanelModel<Options>): Partial<
|
||||
newOptions.sparkline = false;
|
||||
newOptions.effects = { gradient: false };
|
||||
|
||||
// if a display name is set, set the appropriate text mode
|
||||
if ('displayName' in newOptions && newOptions.displayName && newOptions.displayName !== '') {
|
||||
newOptions.textMode = 'value_and_name';
|
||||
}
|
||||
|
||||
// Remove deprecated sizing options
|
||||
if ('sizing' in newOptions) {
|
||||
delete newOptions.sizing;
|
||||
@@ -5,8 +5,8 @@ import { commonOptionsBuilder } from '@grafana/ui';
|
||||
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common';
|
||||
|
||||
import { EffectsEditor } from './EffectsEditor';
|
||||
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
|
||||
import { RadialBarPanel } from './RadialBarPanel';
|
||||
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './migrations';
|
||||
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
|
||||
import { radialBarSuggestionsSupplier } from './suggestions';
|
||||
|
||||
@@ -99,6 +99,22 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
|
||||
showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1,
|
||||
});
|
||||
|
||||
builder.addSelect({
|
||||
path: 'textMode',
|
||||
name: t('radialbar.config.text-mode', 'Text mode'),
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'auto', label: t('radialbar.config.text-mode-auto', 'Auto') },
|
||||
{ value: 'value_and_name', label: t('radialbar.config.text-mode-value-and-name', 'Value and Name') },
|
||||
{ value: 'value', label: t('radialbar.config.text-mode-value', 'Value') },
|
||||
{ value: 'name', label: t('radialbar.config.text-mode-name', 'Name') },
|
||||
{ value: 'none', label: t('radialbar.config.text-mode-none', 'None') },
|
||||
],
|
||||
},
|
||||
defaultValue: defaultOptions.textMode,
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'sparkline',
|
||||
name: t('radialbar.config.sparkline', 'Show sparkline'),
|
||||
|
||||
@@ -42,6 +42,7 @@ composableKinds: PanelCfg: {
|
||||
barWidthFactor: number | *0.5
|
||||
barShape: "flat" | "rounded" | *"flat"
|
||||
endpointMarker?: "point" | "glow" | "none" | *"point"
|
||||
textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto"
|
||||
effects: GaugePanelEffects | *{}
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Options extends common.SingleStatBaseOptions {
|
||||
showThresholdLabels: boolean;
|
||||
showThresholdMarkers: boolean;
|
||||
sparkline?: boolean;
|
||||
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
@@ -46,4 +47,5 @@ export const defaultOptions: Partial<Options> = {
|
||||
showThresholdLabels: false,
|
||||
showThresholdMarkers: true,
|
||||
sparkline: true,
|
||||
textMode: 'auto',
|
||||
};
|
||||
|
||||
@@ -6009,6 +6009,9 @@
|
||||
"name-values-separated-comma": "Hodnoty oddělené čárkou",
|
||||
"selection-options": "Možnosti výběru"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Zpět na seznam",
|
||||
"label-icon": "Ikona",
|
||||
@@ -12620,6 +12623,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Werte werden durch Komma getrennt",
|
||||
"selection-options": "Auswahloptionen"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Zurück zur Liste",
|
||||
"label-icon": "Symbol",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -12519,6 +12519,12 @@
|
||||
"shape-circle": "Circle",
|
||||
"shape-gauge": "Arc",
|
||||
"sparkline": "Show sparkline",
|
||||
"text-mode": "Text mode",
|
||||
"text-mode-auto": "Auto",
|
||||
"text-mode-name": "Name",
|
||||
"text-mode-none": "None",
|
||||
"text-mode-value": "Value",
|
||||
"text-mode-value-and-name": "Value and Name",
|
||||
"threshold-labels": "Show threshold labels",
|
||||
"threshold-markers": "Show thresholds"
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Valores separados por comas",
|
||||
"selection-options": "Opciones de selección"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Regresar a la lista",
|
||||
"label-icon": "Icono",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Valeurs séparées par des virgules",
|
||||
"selection-options": "Options de sélection"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Retour à la liste",
|
||||
"label-icon": "Icône",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Értékek vesszővel elválasztva",
|
||||
"selection-options": "Kijelölés beállításai"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Vissza a listához",
|
||||
"label-icon": "Ikon",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5946,6 +5946,9 @@
|
||||
"name-values-separated-comma": "Nilai dipisahkan dengan koma",
|
||||
"selection-options": "Opsi pemilihan"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Kembali ke daftar",
|
||||
"label-icon": "Ikon",
|
||||
@@ -12464,6 +12467,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Valori separati da virgola",
|
||||
"selection-options": "Seleziona opzioni"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Torna all'elenco",
|
||||
"label-icon": "Icona",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5946,6 +5946,9 @@
|
||||
"name-values-separated-comma": "カンマ区切りの値",
|
||||
"selection-options": "選択オプション"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "一覧に戻る",
|
||||
"label-icon": "アイコン",
|
||||
@@ -12464,6 +12467,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5946,6 +5946,9 @@
|
||||
"name-values-separated-comma": "쉼표로 구분된 값",
|
||||
"selection-options": "선택 옵션"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "목록으로 돌아가기",
|
||||
"label-icon": "아이콘",
|
||||
@@ -12464,6 +12467,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Waarden gescheiden door komma",
|
||||
"selection-options": "Selectiemogelijkheden"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Terug naar lijst",
|
||||
"label-icon": "Pictogram",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -6009,6 +6009,9 @@
|
||||
"name-values-separated-comma": "Wartości rozdzielone przecinkami",
|
||||
"selection-options": "Opcje wyboru"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Powrót do listy",
|
||||
"label-icon": "Ikona",
|
||||
@@ -12620,6 +12623,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Valores separados por vírgula",
|
||||
"selection-options": "Opções de seleção"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Voltar para a lista",
|
||||
"label-icon": "Ícone",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Valores separados por vírgulas",
|
||||
"selection-options": "Opções de seleção"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Voltar à lista",
|
||||
"label-icon": "Ícone",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -6009,6 +6009,9 @@
|
||||
"name-values-separated-comma": "Значения, разделенные запятыми",
|
||||
"selection-options": "Параметры выбора"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Назад к списку",
|
||||
"label-icon": "Значок",
|
||||
@@ -12620,6 +12623,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Värden åtskilda med kommatecken",
|
||||
"selection-options": "Urvalsalternativ"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Tillbaka till listan",
|
||||
"label-icon": "Ikon",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5967,6 +5967,9 @@
|
||||
"name-values-separated-comma": "Virgülle ayrılmış değerler",
|
||||
"selection-options": "Seçim ayarları"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Listeye geri dön",
|
||||
"label-icon": "Simge",
|
||||
@@ -12516,6 +12519,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5946,6 +5946,9 @@
|
||||
"name-values-separated-comma": "以逗号分隔的值",
|
||||
"selection-options": "选择内容选项"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "回到列表",
|
||||
"label-icon": "图标",
|
||||
@@ -12464,6 +12467,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
@@ -5946,6 +5946,9 @@
|
||||
"name-values-separated-comma": "以逗號分隔的值",
|
||||
"selection-options": "選擇選項"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": ""
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "返回清單",
|
||||
"label-icon": "圖示",
|
||||
@@ -12464,6 +12467,12 @@
|
||||
"shape-circle": "",
|
||||
"shape-gauge": "",
|
||||
"sparkline": "",
|
||||
"text-mode": "",
|
||||
"text-mode-auto": "",
|
||||
"text-mode-name": "",
|
||||
"text-mode-none": "",
|
||||
"text-mode-value": "",
|
||||
"text-mode-value-and-name": "",
|
||||
"threshold-labels": "",
|
||||
"threshold-markers": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user