Merge branch 'main' into wb/pluginmeta-local
This commit is contained in:
@@ -71,6 +71,7 @@ public/css/*.min.css
|
|||||||
.vs/
|
.vs/
|
||||||
.cursor/
|
.cursor/
|
||||||
.devcontainer/
|
.devcontainer/
|
||||||
|
.claude/
|
||||||
|
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
|||||||
| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes |
|
| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes |
|
||||||
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
||||||
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
||||||
|
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes |
|
||||||
| `tempoSearchBackendMigration` | Run search queries through the tempo backend | |
|
| `tempoSearchBackendMigration` | Run search queries through the tempo backend | |
|
||||||
|
|
||||||
## Public preview feature toggles
|
## Public preview feature toggles
|
||||||
@@ -95,9 +96,9 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
|||||||
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
|
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
|
||||||
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
|
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
|
||||||
| `interactiveLearning` | Enables the interactive learning app |
|
| `interactiveLearning` | Enables the interactive learning app |
|
||||||
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker |
|
|
||||||
| `newVizSuggestions` | Enable new visualization suggestions |
|
| `newVizSuggestions` | Enable new visualization suggestions |
|
||||||
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
|
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
|
||||||
|
| `newPanelPadding` | Increases panel padding globally |
|
||||||
| `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state |
|
| `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state |
|
||||||
|
|
||||||
## Development feature toggles
|
## Development feature toggles
|
||||||
|
|||||||
@@ -1912,11 +1912,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx": {
|
|
||||||
"@typescript-eslint/consistent-type-assertions": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": {
|
"public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": {
|
||||||
"no-restricted-syntax": {
|
"no-restricted-syntax": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ export class PanelPlugin<
|
|||||||
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
|
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
|
||||||
pluginId: this.meta.id,
|
pluginId: this.meta.id,
|
||||||
name: this.meta.name,
|
name: this.meta.name,
|
||||||
|
options: {},
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = supplier(builder.dataSummary);
|
const result = supplier(builder.dataSummary);
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { PreferredVisualisationType } from '../../types/data';
|
import { PreferredVisualisationType } from '../../types/data';
|
||||||
import { DataFrame, FieldType } from '../../types/dataFrame';
|
import { DataFrame, FieldType } from '../../types/dataFrame';
|
||||||
|
import { DataFrameType } from '../../types/dataFrameTypes';
|
||||||
|
|
||||||
/**
|
|
||||||
* @alpha
|
|
||||||
*/
|
|
||||||
export interface PanelDataSummary {
|
export interface PanelDataSummary {
|
||||||
hasData?: boolean;
|
hasData?: boolean;
|
||||||
rowCountTotal: number;
|
rowCountTotal: number;
|
||||||
|
/** max number of rows in any given dataframe in the panel data */
|
||||||
rowCountMax: number;
|
rowCountMax: number;
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
fieldCount: number;
|
fieldCount: number;
|
||||||
|
/** max number of fields in any given dataframe in the panel data */
|
||||||
|
fieldCountMax: number;
|
||||||
|
/** given a field type, return the number of fields across all dataframes which match this type */
|
||||||
fieldCountByType: (type: FieldType) => number;
|
fieldCountByType: (type: FieldType) => number;
|
||||||
|
/** returns true if any fields in any frames match the field type */
|
||||||
hasFieldType: (type: FieldType) => boolean;
|
hasFieldType: (type: FieldType) => boolean;
|
||||||
/** The first frame that set's this value */
|
/* returns true if any of the frames in this panel data summary have the type */
|
||||||
preferredVisualisationType?: PreferredVisualisationType;
|
hasDataFrameType: (type: DataFrameType) => boolean;
|
||||||
|
/* returns true if any of the frames in this panel data summary have the type */
|
||||||
|
hasPreferredVisualisationType: (type: PreferredVisualisationType) => boolean;
|
||||||
|
|
||||||
|
/** pass along a reference to the DataFrame array in case it's needed by the plugin */
|
||||||
|
rawFrames?: DataFrame[];
|
||||||
|
|
||||||
/* --- DEPRECATED FIELDS BELOW --- */
|
/* --- DEPRECATED FIELDS BELOW --- */
|
||||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
|
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
|
||||||
@@ -23,60 +31,114 @@ export interface PanelDataSummary {
|
|||||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
|
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
|
||||||
stringFieldCount: number;
|
stringFieldCount: number;
|
||||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
|
||||||
hasNumberField?: boolean;
|
|
||||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
|
||||||
hasTimeField?: boolean;
|
hasTimeField?: boolean;
|
||||||
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
||||||
|
hasNumberField?: boolean;
|
||||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
|
||||||
hasStringField?: boolean;
|
hasStringField?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
|
class PanelDataSummaryImpl implements PanelDataSummary {
|
||||||
|
public rowCountTotal = 0;
|
||||||
|
/** max number of rows in any single dataframe in the panel data */
|
||||||
|
public rowCountMax = 0;
|
||||||
|
public fieldCount = 0;
|
||||||
|
/** max number of fields in any single dataframe in the panel data */
|
||||||
|
public fieldCountMax = 0;
|
||||||
|
|
||||||
|
private countByType: Partial<Record<FieldType, number>> = {};
|
||||||
|
private preferredVisualisationTypes: Set<PreferredVisualisationType> = new Set<PreferredVisualisationType>();
|
||||||
|
private dataFrameTypes: Set<DataFrameType> = new Set<DataFrameType>();
|
||||||
|
|
||||||
|
public get hasData(): boolean {
|
||||||
|
return this.rowCountTotal > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get frameCount(): number {
|
||||||
|
return this.rawFrames?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(public rawFrames?: DataFrame[]) {
|
||||||
|
this._processFrames();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _processFrames() {
|
||||||
|
for (const frame of this.rawFrames ?? []) {
|
||||||
|
this.rowCountTotal += frame.length;
|
||||||
|
|
||||||
|
if (frame.meta?.preferredVisualisationType) {
|
||||||
|
this.preferredVisualisationTypes.add(frame.meta.preferredVisualisationType);
|
||||||
|
}
|
||||||
|
if (frame.meta?.type) {
|
||||||
|
this.dataFrameTypes.add(frame.meta.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of frame.fields) {
|
||||||
|
this.fieldCount++;
|
||||||
|
this.countByType[field.type] = (this.countByType[field.type] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.length > this.rowCountMax) {
|
||||||
|
this.rowCountMax = frame.length;
|
||||||
|
}
|
||||||
|
if (frame.fields.length > this.fieldCountMax) {
|
||||||
|
this.fieldCountMax = frame.fields.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fieldCountByType(type: FieldType): number {
|
||||||
|
return this.countByType[type] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasFieldType(type: FieldType): boolean {
|
||||||
|
return this.fieldCountByType(type) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasPreferredVisualisationType(type: PreferredVisualisationType): boolean {
|
||||||
|
return this.preferredVisualisationTypes.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasDataFrameType(type: DataFrameType): boolean {
|
||||||
|
return this.dataFrameTypes.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**** DEPRECATED ****/
|
||||||
|
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
|
||||||
|
public get numberFieldCount(): number {
|
||||||
|
return this.fieldCountByType(FieldType.number);
|
||||||
|
}
|
||||||
|
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.time) */
|
||||||
|
public get timeFieldCount(): number {
|
||||||
|
return this.fieldCountByType(FieldType.time);
|
||||||
|
}
|
||||||
|
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
|
||||||
|
public get stringFieldCount() {
|
||||||
|
return this.fieldCountByType(FieldType.string);
|
||||||
|
}
|
||||||
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
|
||||||
|
public get hasTimeField() {
|
||||||
|
return this.fieldCountByType(FieldType.time) > 0;
|
||||||
|
}
|
||||||
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
||||||
|
public get hasNumberField() {
|
||||||
|
return this.fieldCountByType(FieldType.number) > 0;
|
||||||
|
}
|
||||||
|
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
|
||||||
|
public get hasStringField() {
|
||||||
|
return this.fieldCountByType(FieldType.string) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
* given a list of dataframes, summarize attributes of those frames for features like suggestions.
|
* given a list of dataframes, summarize attributes of those frames for features like suggestions.
|
||||||
* @param frames - dataframes to summarize
|
* @param frames - dataframes to summarize
|
||||||
* @returns summary of the dataframes
|
* @returns summary of the dataframes
|
||||||
*/
|
*/
|
||||||
export function getPanelDataSummary(frames: DataFrame[] = []): PanelDataSummary {
|
export function getPanelDataSummary(frames?: DataFrame[]): PanelDataSummary {
|
||||||
let rowCountTotal = 0;
|
return new PanelDataSummaryImpl(frames);
|
||||||
let rowCountMax = 0;
|
|
||||||
let fieldCount = 0;
|
|
||||||
const countByType: Partial<Record<FieldType, number>> = {};
|
|
||||||
let preferredVisualisationType: PreferredVisualisationType | undefined;
|
|
||||||
|
|
||||||
for (const frame of frames) {
|
|
||||||
rowCountTotal += frame.length;
|
|
||||||
|
|
||||||
if (frame.meta?.preferredVisualisationType) {
|
|
||||||
preferredVisualisationType = frame.meta.preferredVisualisationType;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of frame.fields) {
|
|
||||||
fieldCount++;
|
|
||||||
countByType[field.type] = (countByType[field.type] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frame.length > rowCountMax) {
|
|
||||||
rowCountMax = frame.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldCountByType = (f: FieldType) => countByType[f] ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
rowCountTotal,
|
|
||||||
rowCountMax,
|
|
||||||
fieldCount,
|
|
||||||
preferredVisualisationType,
|
|
||||||
frameCount: frames.length,
|
|
||||||
hasData: rowCountTotal > 0,
|
|
||||||
hasFieldType: (f: FieldType) => fieldCountByType(f) > 0,
|
|
||||||
fieldCountByType,
|
|
||||||
// deprecated
|
|
||||||
numberFieldCount: fieldCountByType(FieldType.number),
|
|
||||||
timeFieldCount: fieldCountByType(FieldType.time),
|
|
||||||
stringFieldCount: fieldCountByType(FieldType.string),
|
|
||||||
hasTimeField: fieldCountByType(FieldType.time) > 0,
|
|
||||||
hasNumberField: fieldCountByType(FieldType.number) > 0,
|
|
||||||
hasStringField: fieldCountByType(FieldType.string) > 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ interface IndexOptions {
|
|||||||
asPercentile: boolean;
|
asPercentile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultReduceOptions: ReduceOptions = {
|
const defaultNumericVizOptions: ReduceOptions = {
|
||||||
reducer: ReducerID.sum,
|
reducer: ReducerID.sum,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,10 +149,10 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
|
|||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CalculateFieldMode.ReduceRow:
|
case CalculateFieldMode.ReduceRow:
|
||||||
creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data);
|
creator = getReduceRowCreator(defaults(options.reduce, defaultNumericVizOptions), data);
|
||||||
break;
|
break;
|
||||||
case CalculateFieldMode.CumulativeFunctions:
|
case CalculateFieldMode.CumulativeFunctions:
|
||||||
creator = getCumulativeCreator(defaults(options.cumulative, defaultReduceOptions), data);
|
creator = getCumulativeCreator(defaults(options.cumulative, defaultNumericVizOptions), data);
|
||||||
break;
|
break;
|
||||||
case CalculateFieldMode.WindowFunctions:
|
case CalculateFieldMode.WindowFunctions:
|
||||||
creator = getWindowCreator(defaults(options.window, defaultWindowOptions), data);
|
creator = getWindowCreator(defaults(options.window, defaultWindowOptions), data);
|
||||||
|
|||||||
+2
-2
@@ -1091,7 +1091,7 @@ export interface FeatureToggles {
|
|||||||
graphiteBackendMode?: boolean;
|
graphiteBackendMode?: boolean;
|
||||||
/**
|
/**
|
||||||
* Enables the updated Azure Monitor resource picker
|
* Enables the updated Azure Monitor resource picker
|
||||||
* @default false
|
* @default true
|
||||||
*/
|
*/
|
||||||
azureResourcePickerUpdates?: boolean;
|
azureResourcePickerUpdates?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -1150,7 +1150,7 @@ export interface FeatureToggles {
|
|||||||
pluginStoreServiceLoading?: boolean;
|
pluginStoreServiceLoading?: boolean;
|
||||||
/**
|
/**
|
||||||
* Increases panel padding globally
|
* Increases panel padding globally
|
||||||
* @default false
|
* @default true
|
||||||
*/
|
*/
|
||||||
newPanelPadding?: boolean;
|
newPanelPadding?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export interface VisualizationSuggestion<TOptions extends unknown = {}, TFieldCo
|
|||||||
* mutate the suggestion object which is passed in as the first argument.
|
* mutate the suggestion object which is passed in as the first argument.
|
||||||
*/
|
*/
|
||||||
previewModifier?: (suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) => void;
|
previewModifier?: (suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) => void;
|
||||||
|
/** @deprecated this will no longer be supported in the new Suggestions UI. */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
/** @deprecated this will no longer be supported in the new Suggestions UI. */
|
||||||
imgSrc?: string;
|
imgSrc?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { dateTime, makeTimeRange, TimeRange } from '@grafana/data';
|
import { dateTime, makeTimeRange, TimeRange, BootData } from '@grafana/data';
|
||||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { TimeRangeProvider } from './TimeRangeContext';
|
import { TimeRangeProvider } from './TimeRangeContext';
|
||||||
@@ -152,6 +152,58 @@ it('does not submit wrapping forms', async () => {
|
|||||||
expect(onSubmit).not.toHaveBeenCalled();
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows CTRL+Z in zoom out tooltip when feature flag is disabled', async () => {
|
||||||
|
window.grafanaBootData = {
|
||||||
|
settings: {
|
||||||
|
featureToggles: {
|
||||||
|
newTimeRangeZoomShortcuts: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as BootData;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TimeRangePicker
|
||||||
|
onChangeTimeZone={() => {}}
|
||||||
|
onChange={(value) => {}}
|
||||||
|
value={value}
|
||||||
|
onMoveBackward={() => {}}
|
||||||
|
onMoveForward={() => {}}
|
||||||
|
onZoom={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomButton = screen.getByLabelText('Zoom out time range');
|
||||||
|
await userEvent.hover(zoomButton);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/CTRL\+Z/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows t - in zoom out tooltip when feature flag is enabled', async () => {
|
||||||
|
window.grafanaBootData = {
|
||||||
|
settings: {
|
||||||
|
featureToggles: {
|
||||||
|
newTimeRangeZoomShortcuts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as BootData;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TimeRangePicker
|
||||||
|
onChangeTimeZone={() => {}}
|
||||||
|
onChange={(value) => {}}
|
||||||
|
value={value}
|
||||||
|
onMoveBackward={() => {}}
|
||||||
|
onMoveForward={() => {}}
|
||||||
|
onZoom={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomButton = screen.getByLabelText('Zoom out time range');
|
||||||
|
await userEvent.hover(zoomButton);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/t -/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
describe('TimePickerTooltip', () => {
|
describe('TimePickerTooltip', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mockIntl = {
|
const mockIntl = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
|||||||
import { t, Trans } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
|
import { getFeatureToggle } from '../../utils/featureToggle';
|
||||||
import { ButtonGroup } from '../Button/ButtonGroup';
|
import { ButtonGroup } from '../Button/ButtonGroup';
|
||||||
import { getModalStyles } from '../Modal/getModalStyles';
|
import { getModalStyles } from '../Modal/getModalStyles';
|
||||||
import { getPortalContainer } from '../Portal/Portal';
|
import { getPortalContainer } from '../Portal/Portal';
|
||||||
@@ -243,13 +244,22 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
|
|||||||
|
|
||||||
TimeRangePicker.displayName = 'TimeRangePicker';
|
TimeRangePicker.displayName = 'TimeRangePicker';
|
||||||
|
|
||||||
const ZoomOutTooltip = () => (
|
const ZoomOutTooltip = () => {
|
||||||
<>
|
const newShortcuts = getFeatureToggle('newTimeRangeZoomShortcuts');
|
||||||
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip">
|
return (
|
||||||
Time range zoom out <br /> CTRL+Z
|
<>
|
||||||
</Trans>
|
{newShortcuts ? (
|
||||||
</>
|
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip-new">
|
||||||
);
|
Time range zoom out <br /> t -
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip">
|
||||||
|
Time range zoom out <br /> CTRL+Z
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; timeZone?: TimeZone }) => {
|
export const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; timeZone?: TimeZone }) => {
|
||||||
const styles = useStyles2(getLabelStyles);
|
const styles = useStyles2(getLabelStyles);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const cursorDefaults: Cursor = {
|
|||||||
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
||||||
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
|
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
|
||||||
|
|
||||||
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number };
|
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number; isTimeRangePending?: boolean };
|
||||||
|
|
||||||
export class UPlotConfigBuilder {
|
export class UPlotConfigBuilder {
|
||||||
readonly uid = Math.random().toString(36).slice(2);
|
readonly uid = Math.random().toString(36).slice(2);
|
||||||
|
|||||||
+15
-1
@@ -137,7 +137,7 @@ describe('XAxisInteractionAreaPlugin', () => {
|
|||||||
expect(mockQueryZoom).not.toHaveBeenCalled();
|
expect(mockQueryZoom).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isPanning state during drag and clear on mouseup', () => {
|
it('should set isPanning state during drag and mark isTimeRangePending on mouseup', () => {
|
||||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||||
|
|
||||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||||
@@ -153,6 +153,20 @@ describe('XAxisInteractionAreaPlugin', () => {
|
|||||||
|
|
||||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
||||||
|
|
||||||
|
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({
|
||||||
|
isPanning: true,
|
||||||
|
min: expectedRange.from,
|
||||||
|
max: expectedRange.to,
|
||||||
|
isTimeRangePending: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear isPanning state immediately for small drags below threshold', () => {
|
||||||
|
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||||
|
|
||||||
|
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||||
|
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true }));
|
||||||
|
|
||||||
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false });
|
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,11 +96,14 @@ export const setupXAxisPan = (
|
|||||||
|
|
||||||
xAxisEl.style.cursor = 'grab';
|
xAxisEl.style.cursor = 'grab';
|
||||||
|
|
||||||
config.setState({ isPanning: false });
|
const isSignificantDrag = Math.abs(dragPixels) >= MIN_PAN_DIST;
|
||||||
|
|
||||||
if (Math.abs(dragPixels) >= MIN_PAN_DIST) {
|
if (isSignificantDrag) {
|
||||||
const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
|
const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
|
||||||
|
config.setState({ isPanning: true, min: newRange.from, max: newRange.to, isTimeRangePending: true });
|
||||||
queryZoom(newRange);
|
queryZoom(newRange);
|
||||||
|
} else {
|
||||||
|
config.setState({ isPanning: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
|||||||
Generated
+2
-2
@@ -668,7 +668,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
|||||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1312,7 +1312,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
|||||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1801,10 +1801,10 @@ var (
|
|||||||
{
|
{
|
||||||
Name: "azureResourcePickerUpdates",
|
Name: "azureResourcePickerUpdates",
|
||||||
Description: "Enables the updated Azure Monitor resource picker",
|
Description: "Enables the updated Azure Monitor resource picker",
|
||||||
Stage: FeatureStagePublicPreview,
|
Stage: FeatureStageGeneralAvailability,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
Owner: grafanaPartnerPluginsSquad,
|
Owner: grafanaPartnerPluginsSquad,
|
||||||
Expression: "false",
|
Expression: "true",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "prometheusTypeMigration",
|
Name: "prometheusTypeMigration",
|
||||||
@@ -1895,10 +1895,10 @@ var (
|
|||||||
{
|
{
|
||||||
Name: "newPanelPadding",
|
Name: "newPanelPadding",
|
||||||
Description: "Increases panel padding globally",
|
Description: "Increases panel padding globally",
|
||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStagePublicPreview,
|
||||||
FrontendOnly: false,
|
FrontendOnly: true,
|
||||||
Owner: grafanaDashboardsSquad,
|
Owner: grafanaDashboardsSquad,
|
||||||
Expression: "false",
|
Expression: "true",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "onlyStoreActionSets",
|
Name: "onlyStoreActionSets",
|
||||||
|
|||||||
Generated
+2
-2
@@ -245,7 +245,7 @@ teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,f
|
|||||||
interactiveLearning,preview,@grafana/pathfinder,false,false,false
|
interactiveLearning,preview,@grafana/pathfinder,false,false,false
|
||||||
alertingTriage,experimental,@grafana/alerting-squad,false,false,false
|
alertingTriage,experimental,@grafana/alerting-squad,false,false,false
|
||||||
graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false
|
graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false
|
||||||
azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true
|
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
||||||
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
||||||
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
||||||
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
||||||
@@ -257,7 +257,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
|||||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||||
newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false
|
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||||
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
||||||
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||||
|
|||||||
|
Generated
-4
@@ -742,10 +742,6 @@ const (
|
|||||||
// Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
// Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
||||||
FlagPluginStoreServiceLoading = "pluginStoreServiceLoading"
|
FlagPluginStoreServiceLoading = "pluginStoreServiceLoading"
|
||||||
|
|
||||||
// FlagNewPanelPadding
|
|
||||||
// Increases panel padding globally
|
|
||||||
FlagNewPanelPadding = "newPanelPadding"
|
|
||||||
|
|
||||||
// FlagOnlyStoreActionSets
|
// FlagOnlyStoreActionSets
|
||||||
// When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission
|
// When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission
|
||||||
FlagOnlyStoreActionSets = "onlyStoreActionSets"
|
FlagOnlyStoreActionSets = "onlyStoreActionSets"
|
||||||
|
|||||||
+15
-10
@@ -551,7 +551,6 @@
|
|||||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||||
"stage": "experimental",
|
"stage": "experimental",
|
||||||
"codeowner": "@grafana/alerting-squad",
|
"codeowner": "@grafana/alerting-squad",
|
||||||
"hideFromAdminPage": true,
|
|
||||||
"hideFromDocs": true
|
"hideFromDocs": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||||
"stage": "experimental",
|
"stage": "experimental",
|
||||||
"codeowner": "@grafana/alerting-squad",
|
"codeowner": "@grafana/alerting-squad",
|
||||||
"hideFromAdminPage": true,
|
|
||||||
"hideFromDocs": true
|
"hideFromDocs": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -768,16 +766,19 @@
|
|||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "azureResourcePickerUpdates",
|
"name": "azureResourcePickerUpdates",
|
||||||
"resourceVersion": "1763734583253",
|
"resourceVersion": "1764153435365",
|
||||||
"creationTimestamp": "2025-07-31T22:56:50Z",
|
"creationTimestamp": "2025-07-31T22:56:50Z",
|
||||||
"deletionTimestamp": "2025-08-01T11:30:17Z"
|
"deletionTimestamp": "2025-08-01T11:30:17Z",
|
||||||
|
"annotations": {
|
||||||
|
"grafana.app/updatedTimestamp": "2025-11-26 10:37:15.365919 +0000 UTC"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"description": "Enables the updated Azure Monitor resource picker",
|
"description": "Enables the updated Azure Monitor resource picker",
|
||||||
"stage": "preview",
|
"stage": "GA",
|
||||||
"codeowner": "@grafana/partner-datasources",
|
"codeowner": "@grafana/partner-datasources",
|
||||||
"frontend": true,
|
"frontend": true,
|
||||||
"expression": "false"
|
"expression": "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2361,14 +2362,18 @@
|
|||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "newPanelPadding",
|
"name": "newPanelPadding",
|
||||||
"resourceVersion": "1763734583253",
|
"resourceVersion": "1764168915089",
|
||||||
"creationTimestamp": "2025-11-12T15:40:46Z"
|
"creationTimestamp": "2025-11-12T15:40:46Z",
|
||||||
|
"annotations": {
|
||||||
|
"grafana.app/updatedTimestamp": "2025-11-26 14:55:15.089551 +0000 UTC"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"description": "Increases panel padding globally",
|
"description": "Increases panel padding globally",
|
||||||
"stage": "experimental",
|
"stage": "preview",
|
||||||
"codeowner": "@grafana/dashboards-squad",
|
"codeowner": "@grafana/dashboards-squad",
|
||||||
"expression": "false"
|
"frontend": true,
|
||||||
|
"expression": "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -165,6 +165,21 @@ func (aq *AlertQuery) setMaxDatapoints() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRefID sets the model refId if it's missing or invalid
|
||||||
|
func (aq *AlertQuery) setRefID() error {
|
||||||
|
if aq.modelProps == nil {
|
||||||
|
err := aq.setModelProps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if refID, ok := aq.modelProps["refId"].(string); !ok || refID != aq.RefID {
|
||||||
|
aq.modelProps["refId"] = aq.RefID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (aq *AlertQuery) GetMaxDatapoints() (int64, error) {
|
func (aq *AlertQuery) GetMaxDatapoints() (int64, error) {
|
||||||
err := aq.setMaxDatapoints()
|
err := aq.setMaxDatapoints()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -256,6 +271,11 @@ func (aq *AlertQuery) GetModel() ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = aq.setRefID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = aq.setIntervalMS()
|
err = aq.setIntervalMS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dashboards
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,10 +10,12 @@ import (
|
|||||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||||
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ type DashboardProvisioner interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DashboardProvisionerFactory creates DashboardProvisioners based on input
|
// DashboardProvisionerFactory creates DashboardProvisioners based on input
|
||||||
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (DashboardProvisioner, error)
|
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (DashboardProvisioner, error)
|
||||||
|
|
||||||
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
|
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
|
||||||
type Provisioner struct {
|
type Provisioner struct {
|
||||||
@@ -38,6 +41,8 @@ type Provisioner struct {
|
|||||||
duplicateValidator duplicateValidator
|
duplicateValidator duplicateValidator
|
||||||
provisioner dashboards.DashboardProvisioningService
|
provisioner dashboards.DashboardProvisioningService
|
||||||
dual dualwrite.Service
|
dual dualwrite.Service
|
||||||
|
serverLock *serverlock.ServerLockService
|
||||||
|
cfg *setting.Cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Provisioner) HasDashboardSources() bool {
|
func (provider *Provisioner) HasDashboardSources() bool {
|
||||||
@@ -45,7 +50,7 @@ func (provider *Provisioner) HasDashboardSources() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new DashboardProvisioner
|
// New returns a new DashboardProvisioner
|
||||||
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service) (DashboardProvisioner, error) {
|
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, cfg *setting.Cfg, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service, serverLockService *serverlock.ServerLockService) (DashboardProvisioner, error) {
|
||||||
logger := log.New("provisioning.dashboard")
|
logger := log.New("provisioning.dashboard")
|
||||||
cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)}
|
cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)}
|
||||||
configs, err := cfgReader.readConfig(ctx)
|
configs, err := cfgReader.readConfig(ctx)
|
||||||
@@ -78,6 +83,8 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das
|
|||||||
duplicateValidator: newDuplicateValidator(logger, fileReaders),
|
duplicateValidator: newDuplicateValidator(logger, fileReaders),
|
||||||
provisioner: provisioner,
|
provisioner: provisioner,
|
||||||
dual: dual,
|
dual: dual,
|
||||||
|
serverLock: serverLockService,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
@@ -95,23 +102,53 @@ func (provider *Provisioner) Provision(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.log.Info("starting to provision dashboards")
|
var errProvisioning error
|
||||||
|
|
||||||
for _, reader := range provider.fileReaders {
|
// retry obtaining the lock for 20 attempts
|
||||||
if err := reader.walkDisk(ctx); err != nil {
|
retryOpt := func(attempts int) error {
|
||||||
if os.IsNotExist(err) {
|
if attempts < 20 {
|
||||||
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
return nil
|
||||||
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
|
||||||
}
|
}
|
||||||
|
return errors.New("retries exhausted")
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.duplicateValidator.validate()
|
lockTimeConfig := serverlock.LockTimeConfig{
|
||||||
provider.log.Info("finished to provision dashboards")
|
// if a replica crashes while holding the lock, other replicas can obtain the
|
||||||
return nil
|
// lock after this duration (15s default value, might be configured via config file)
|
||||||
|
MaxInterval: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds) * time.Second,
|
||||||
|
|
||||||
|
// wait beetween 100ms and 1s before retrying to obtain the lock (default values, might be configured via config file)
|
||||||
|
MinWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMinWaitMs) * time.Millisecond,
|
||||||
|
MaxWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs) * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// this means that if we fail to obtain the lock after ~10 seconds, we return an error
|
||||||
|
lockErr := provider.serverLock.LockExecuteAndReleaseWithRetries(ctx, "provisioning_dashboards", lockTimeConfig, func(ctx context.Context) {
|
||||||
|
provider.log.Info("starting to provision dashboards")
|
||||||
|
|
||||||
|
for _, reader := range provider.fileReaders {
|
||||||
|
if err := reader.walkDisk(ctx); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
||||||
|
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errProvisioning = fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.duplicateValidator.validate()
|
||||||
|
provider.log.Info("finished to provision dashboards")
|
||||||
|
}, retryOpt)
|
||||||
|
|
||||||
|
if lockErr != nil {
|
||||||
|
provider.log.Error("Failed to obtain dashboard provisioning lock", "error", lockErr)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return errProvisioning
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
|
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/grafana/dskit/services"
|
"github.com/grafana/dskit/services"
|
||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
@@ -64,6 +65,7 @@ func ProvideService(
|
|||||||
tracer tracing.Tracer,
|
tracer tracing.Tracer,
|
||||||
dual dualwrite.Service,
|
dual dualwrite.Service,
|
||||||
promTypeMigrationProvider promtypemigration.PromTypeMigrationProvider,
|
promTypeMigrationProvider promtypemigration.PromTypeMigrationProvider,
|
||||||
|
serverLockService *serverlock.ServerLockService,
|
||||||
) (*ProvisioningServiceImpl, error) {
|
) (*ProvisioningServiceImpl, error) {
|
||||||
s := &ProvisioningServiceImpl{
|
s := &ProvisioningServiceImpl{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
@@ -92,6 +94,7 @@ func ProvideService(
|
|||||||
tracer: tracer,
|
tracer: tracer,
|
||||||
migratePrometheusType: promTypeMigrationProvider.Run,
|
migratePrometheusType: promTypeMigrationProvider.Run,
|
||||||
dual: dual,
|
dual: dual,
|
||||||
|
serverLock: serverLockService,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.NamedService = services.NewBasicService(s.starting, s.running, nil).WithName(ServiceName)
|
s.NamedService = services.NewBasicService(s.starting, s.running, nil).WithName(ServiceName)
|
||||||
@@ -166,7 +169,7 @@ func (ps *ProvisioningServiceImpl) running(ctx context.Context) error {
|
|||||||
|
|
||||||
func (ps *ProvisioningServiceImpl) setDashboardProvisioner() error {
|
func (ps *ProvisioningServiceImpl) setDashboardProvisioner() error {
|
||||||
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
|
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
|
||||||
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService, ps.folderService, ps.dual)
|
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.Cfg, ps.orgService, ps.dashboardService, ps.folderService, ps.dual, ps.serverLock)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%v: %w", "Failed to create provisioner", err)
|
return fmt.Errorf("%v: %w", "Failed to create provisioner", err)
|
||||||
}
|
}
|
||||||
@@ -242,6 +245,7 @@ type ProvisioningServiceImpl struct {
|
|||||||
resourcePermissions accesscontrol.ReceiverPermissionsService
|
resourcePermissions accesscontrol.ReceiverPermissionsService
|
||||||
tracer tracing.Tracer
|
tracer tracing.Tracer
|
||||||
dual dualwrite.Service
|
dual dualwrite.Service
|
||||||
|
serverLock *serverlock.ServerLockService
|
||||||
migratePrometheusType func(context.Context) error
|
migratePrometheusType func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
dashboardstore "github.com/grafana/grafana/pkg/services/dashboards"
|
dashboardstore "github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||||
"github.com/grafana/grafana/pkg/services/searchV2"
|
"github.com/grafana/grafana/pkg/services/searchV2"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ func setup(t *testing.T) *serviceTestStruct {
|
|||||||
searchStub := searchV2.NewStubSearchService()
|
searchStub := searchV2.NewStubSearchService()
|
||||||
|
|
||||||
service, err := newProvisioningServiceImpl(
|
service, err := newProvisioningServiceImpl(
|
||||||
func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (dashboards.DashboardProvisioner, error) {
|
func(context.Context, string, dashboardstore.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (dashboards.DashboardProvisioner, error) {
|
||||||
serviceTest.dashboardProvisionerInstantiations++
|
serviceTest.dashboardProvisionerInstantiations++
|
||||||
return serviceTest.mock, nil
|
return serviceTest.mock, nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ type Cfg struct {
|
|||||||
PluginsPath string
|
PluginsPath string
|
||||||
EnterpriseLicensePath string
|
EnterpriseLicensePath string
|
||||||
|
|
||||||
|
// Classic Provisioning settings
|
||||||
|
ClassicProvisioningDashboardsServerLockMaxIntervalSeconds int64
|
||||||
|
ClassicProvisioningDashboardsServerLockMinWaitMs int64
|
||||||
|
ClassicProvisioningDashboardsServerLockMaxWaitMs int64
|
||||||
|
|
||||||
// SMTP email settings
|
// SMTP email settings
|
||||||
Smtp SmtpSettings
|
Smtp SmtpSettings
|
||||||
|
|
||||||
@@ -1221,6 +1226,8 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.readClassicProvisioningSettings(iniFile)
|
||||||
|
|
||||||
// read dashboard settings
|
// read dashboard settings
|
||||||
dashboards := iniFile.Section("dashboards")
|
dashboards := iniFile.Section("dashboards")
|
||||||
cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20)
|
cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20)
|
||||||
@@ -2107,6 +2114,12 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Cfg) readClassicProvisioningSettings(iniFile *ini.File) {
|
||||||
|
cfg.ClassicProvisioningDashboardsServerLockMinWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_min_wait_ms").MustInt64(100)
|
||||||
|
cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_wait_ms").MustInt64(1000)
|
||||||
|
cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_interval_seconds").MustInt64(15)
|
||||||
|
}
|
||||||
|
|
||||||
func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
|
func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
|
||||||
provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "")
|
provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "")
|
||||||
cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath)
|
cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath)
|
||||||
|
|||||||
@@ -202,30 +202,58 @@ then run:
|
|||||||
kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml
|
kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run as a GRPC service
|
### Run as a separate GRPC service
|
||||||
|
|
||||||
#### Start GRPC storage-server
|
It is recommended to use a separate config file for the storage-server. Create a file `conf/storage-server.ini` with the following content:
|
||||||
|
|
||||||
Make sure you have the gRPC address in the `[grafana-apiserver]` section of your config file:
|
|
||||||
```ini
|
```ini
|
||||||
|
app_mode = development
|
||||||
|
|
||||||
|
target = storage-server
|
||||||
|
|
||||||
|
[database]
|
||||||
|
type = mysql
|
||||||
|
host = 127.0.0.1:3306
|
||||||
|
name = unified-storage
|
||||||
|
user = root
|
||||||
|
password = rootpass
|
||||||
|
skip_migrations = true
|
||||||
|
ensure_default_org_and_user = false
|
||||||
|
|
||||||
|
[grpc_server]
|
||||||
|
network = "tcp"
|
||||||
|
address = "127.0.0.1:10000"
|
||||||
|
|
||||||
[grafana-apiserver]
|
[grafana-apiserver]
|
||||||
; your gRPC server address
|
storage_type = unified
|
||||||
address = localhost:10000
|
|
||||||
```
|
|
||||||
|
|
||||||
You also need the `[grpc_server_authentication]` section to authenticate incoming requests:
|
|
||||||
```ini
|
|
||||||
[grpc_server_authentication]
|
[grpc_server_authentication]
|
||||||
; http url to Grafana's signing keys to validate incoming id tokens
|
signing_keys_url = http://localhost:3011/api/signing-keys/keys
|
||||||
signing_keys_url = http://localhost:3000/api/signing-keys/keys
|
|
||||||
mode = "on-prem"
|
mode = "on-prem"
|
||||||
|
|
||||||
|
[feature_toggles]
|
||||||
|
kubernetesDashboards = true
|
||||||
|
kubernetesFolders = true
|
||||||
|
unifiedStorage = true
|
||||||
|
unifiedStorageHistoryPruner = true
|
||||||
|
unifiedStorageSearch = true
|
||||||
|
unifiedStorageSearchPermissionFiltering = false
|
||||||
|
unifiedStorageSearchSprinkles = false
|
||||||
|
|
||||||
|
[unified_storage]
|
||||||
|
enable_search = true
|
||||||
|
https_skip_verify = true
|
||||||
```
|
```
|
||||||
|
|
||||||
This currently only works with a separate database configuration (see previous section).
|
You should also have a MySQL database running. You can create one with our docker blocks by running:
|
||||||
|
```bash
|
||||||
|
make devenv sources=mysql
|
||||||
|
```
|
||||||
|
The database credentials in the example above will work with the default mysql docker block. You'll also need to create a database named `unified-storage`.
|
||||||
|
|
||||||
Start the storage-server with:
|
Start the storage-server with:
|
||||||
```sh
|
```sh
|
||||||
GF_DEFAULT_TARGET=storage-server ./bin/grafana server target
|
./bin/grafana server target --config conf/storage-server.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
The GRPC service will listen on port 10000
|
The GRPC service will listen on port 10000
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
|||||||
"rules": [{
|
"rules": [{
|
||||||
"state": "inactive",
|
"state": "inactive",
|
||||||
"name": "AlwaysFiring",
|
"name": "AlwaysFiring",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"duration": 10,
|
"duration": 10,
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
@@ -270,7 +270,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
|||||||
}, {
|
}, {
|
||||||
"state": "inactive",
|
"state": "inactive",
|
||||||
"name": "AlwaysFiringButSilenced",
|
"name": "AlwaysFiringButSilenced",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
"health": "ok",
|
"health": "ok",
|
||||||
@@ -317,7 +317,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
|||||||
"rules": [{
|
"rules": [{
|
||||||
"state": "inactive",
|
"state": "inactive",
|
||||||
"name": "AlwaysFiring",
|
"name": "AlwaysFiring",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"duration": 10,
|
"duration": 10,
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
@@ -335,7 +335,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
|||||||
}, {
|
}, {
|
||||||
"state": "inactive",
|
"state": "inactive",
|
||||||
"name": "AlwaysFiringButSilenced",
|
"name": "AlwaysFiringButSilenced",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
"health": "ok",
|
"health": "ok",
|
||||||
@@ -639,7 +639,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
|||||||
"name": "AlwaysFiring",
|
"name": "AlwaysFiring",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"duration": 10,
|
"duration": 10,
|
||||||
"keepFiringFor": 15,
|
"keepFiringFor": 15,
|
||||||
"annotations": {
|
"annotations": {
|
||||||
@@ -656,7 +656,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
|||||||
"name": "AlwaysFiringButSilenced",
|
"name": "AlwaysFiringButSilenced",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"health": "ok",
|
"health": "ok",
|
||||||
"isPaused": false,
|
"isPaused": false,
|
||||||
"type": "alerting",
|
"type": "alerting",
|
||||||
@@ -688,7 +688,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
|||||||
"name": "AlwaysFiring",
|
"name": "AlwaysFiring",
|
||||||
"uid": "%s",
|
"uid": "%s",
|
||||||
"folderUid": "default",
|
"folderUid": "default",
|
||||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||||
"duration": 10,
|
"duration": 10,
|
||||||
"keepFiringFor": 15,
|
"keepFiringFor": 15,
|
||||||
"annotations": {
|
"annotations": {
|
||||||
|
|||||||
@@ -1166,6 +1166,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
|||||||
"expression": "2 + 3 \u003e 1",
|
"expression": "2 + 3 \u003e 1",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
@@ -1209,6 +1210,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
|||||||
"expression": "2 + 3 \u003e 1",
|
"expression": "2 + 3 \u003e 1",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
@@ -1264,6 +1266,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
|||||||
"expression": "2 + 3 \u003e 1",
|
"expression": "2 + 3 \u003e 1",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
@@ -1610,7 +1613,7 @@ func TestIntegrationRuleCreate(t *testing.T) {
|
|||||||
To: apimodels.Duration(15 * time.Minute),
|
To: apimodels.Duration(15 * time.Minute),
|
||||||
},
|
},
|
||||||
DatasourceUID: expr.DatasourceUID,
|
DatasourceUID: expr.DatasourceUID,
|
||||||
Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"type":"math"}`),
|
Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}`),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
UpdatedBy: &apimodels.UserInfo{
|
UpdatedBy: &apimodels.UserInfo{
|
||||||
@@ -2681,6 +2684,7 @@ func TestIntegrationQuota(t *testing.T) {
|
|||||||
"expression":"2 + 4 \u003E 1",
|
"expression":"2 + 4 \u003E 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2798,6 +2802,7 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
|
|||||||
"expression": "2 + 3 > 1",
|
"expression": "2 + 3 > 1",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3285,6 +3290,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003e 1",
|
"expression":"2 + 3 \u003e 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3331,6 +3337,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003e 1",
|
"expression":"2 + 3 \u003e 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3683,6 +3690,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003e 1",
|
"expression":"2 + 3 \u003e 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3729,6 +3737,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003e 1",
|
"expression":"2 + 3 \u003e 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3872,6 +3881,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003C 1",
|
"expression":"2 + 3 \u003C 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3995,6 +4005,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003C 1",
|
"expression":"2 + 3 \u003C 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4093,6 +4104,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
|||||||
"expression":"2 + 3 \u003C 1",
|
"expression":"2 + 3 \u003C 1",
|
||||||
"intervalMs":1000,
|
"intervalMs":1000,
|
||||||
"maxDataPoints":43200,
|
"maxDataPoints":43200,
|
||||||
|
"refId":"A",
|
||||||
"type":"math"
|
"type":"math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"expression": "0 \u003e 0",
|
"expression": "0 \u003e 0",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"expression": "0 == 0",
|
"expression": "0 == 0",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"expression": "0/0",
|
"expression": "0/0",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"expression": "0 \u003e 0",
|
"expression": "0 \u003e 0",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"expression": "0 == 0",
|
"expression": "0 == 0",
|
||||||
"intervalMs": 1000,
|
"intervalMs": 1000,
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
|
"refId": "A",
|
||||||
"type": "math"
|
"type": "math"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,26 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
|
|||||||
range: () => {
|
range: () => {
|
||||||
const state = builder.getState();
|
const state = builder.getState();
|
||||||
if (state.isPanning) {
|
if (state.isPanning) {
|
||||||
|
if (state.isTimeRangePending) {
|
||||||
|
const timeRange = getTimeRange();
|
||||||
|
const propsFrom = timeRange.from.valueOf();
|
||||||
|
const propsTo = timeRange.to.valueOf();
|
||||||
|
|
||||||
|
const MIN_TIMESPAN_MS = 1;
|
||||||
|
const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS;
|
||||||
|
const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS;
|
||||||
|
const timeRangeHasUpdated = fromMatches && toMatches;
|
||||||
|
|
||||||
|
if (timeRangeHasUpdated) {
|
||||||
|
builder.setState({ isPanning: false });
|
||||||
|
return [propsFrom, propsTo];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [state.min, state.max];
|
return [state.min, state.max];
|
||||||
}
|
}
|
||||||
const r = getTimeRange();
|
const timeRange = getTimeRange();
|
||||||
return [r.from.valueOf(), r.to.valueOf()];
|
return [timeRange.from.valueOf(), timeRange.to.valueOf()];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,24 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
|
|||||||
range: (u) => {
|
range: (u) => {
|
||||||
const state = builder.getState();
|
const state = builder.getState();
|
||||||
if (state.isPanning) {
|
if (state.isPanning) {
|
||||||
|
if (state.isTimeRangePending) {
|
||||||
|
const propsRange = coreConfig.xRange(u);
|
||||||
|
const propsFrom = propsRange[0];
|
||||||
|
const propsTo = propsRange[1];
|
||||||
|
|
||||||
|
if (propsFrom != null && propsTo != null) {
|
||||||
|
const MIN_TIMESPAN_MS = 1;
|
||||||
|
const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS;
|
||||||
|
const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS;
|
||||||
|
const timeRangeHasUpdated = fromMatches && toMatches;
|
||||||
|
|
||||||
|
if (timeRangeHasUpdated) {
|
||||||
|
builder.setState({ isPanning: false });
|
||||||
|
return propsRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [state.min, state.max];
|
return [state.min, state.max];
|
||||||
}
|
}
|
||||||
return coreConfig.xRange(u);
|
return coreConfig.xRange(u);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jest.mock('./scopesUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mockScopeServicesState = {
|
const mockScopeServicesState = {
|
||||||
updateNode: jest.fn(),
|
filterNode: jest.fn(),
|
||||||
selectScope: jest.fn(),
|
selectScope: jest.fn(),
|
||||||
resetSelection: jest.fn(),
|
resetSelection: jest.fn(),
|
||||||
nodes: {},
|
nodes: {},
|
||||||
@@ -99,12 +99,12 @@ describe('useRegisterScopesActions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should register scope tree actions and return scopesRow when scopes are selected', () => {
|
it('should register scope tree actions and return scopesRow when scopes are selected', () => {
|
||||||
const mockUpdateNode = jest.fn();
|
const mockFilterNode = jest.fn();
|
||||||
|
|
||||||
// First run with empty scopes in the scopes service
|
// First run with empty scopes in the scopes service
|
||||||
(useScopeServicesState as jest.Mock).mockReturnValue({
|
(useScopeServicesState as jest.Mock).mockReturnValue({
|
||||||
...mockScopeServicesState,
|
...mockScopeServicesState,
|
||||||
updateNode: mockUpdateNode,
|
filterNode: mockFilterNode,
|
||||||
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
|
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,14 +112,14 @@ describe('useRegisterScopesActions', () => {
|
|||||||
return useRegisterScopesActions('', jest.fn());
|
return useRegisterScopesActions('', jest.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('', true, '');
|
expect(mockFilterNode).toHaveBeenCalledWith('', '');
|
||||||
expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]);
|
expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]);
|
||||||
expect(result.current.scopesRow).toBeDefined();
|
expect(result.current.scopesRow).toBeDefined();
|
||||||
|
|
||||||
// Simulate loading of scopes in the service
|
// Simulate loading of scopes in the service
|
||||||
(useScopeServicesState as jest.Mock).mockReturnValue({
|
(useScopeServicesState as jest.Mock).mockReturnValue({
|
||||||
...mockScopeServicesState,
|
...mockScopeServicesState,
|
||||||
updateNode: mockUpdateNode,
|
filterNode: mockFilterNode,
|
||||||
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
|
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
|
||||||
nodes,
|
nodes,
|
||||||
tree,
|
tree,
|
||||||
@@ -151,12 +151,12 @@ describe('useRegisterScopesActions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should load next level of scopes', () => {
|
it('should load next level of scopes', () => {
|
||||||
const mockUpdateNode = jest.fn();
|
const mockFilterNode = jest.fn();
|
||||||
|
|
||||||
// First run with empty scopes in the scopes service
|
// First run with empty scopes in the scopes service
|
||||||
(useScopeServicesState as jest.Mock).mockReturnValue({
|
(useScopeServicesState as jest.Mock).mockReturnValue({
|
||||||
...mockScopeServicesState,
|
...mockScopeServicesState,
|
||||||
updateNode: mockUpdateNode,
|
filterNode: mockFilterNode,
|
||||||
nodes,
|
nodes,
|
||||||
tree,
|
tree,
|
||||||
});
|
});
|
||||||
@@ -165,7 +165,7 @@ describe('useRegisterScopesActions', () => {
|
|||||||
return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
|
return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, '');
|
expect(mockFilterNode).toHaveBeenCalledWith('scope1', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not return component if no scopes are selected', () => {
|
it('does not return component if no scopes are selected', () => {
|
||||||
@@ -259,12 +259,12 @@ describe('useRegisterScopesActions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not use global scope search when searching in some deeper scope category', async () => {
|
it('should not use global scope search when searching in some deeper scope category', async () => {
|
||||||
const mockUpdateNode = jest.fn();
|
const mockFilterNode = jest.fn();
|
||||||
|
|
||||||
// First run with empty scopes in the scopes service
|
// First run with empty scopes in the scopes service
|
||||||
(useScopeServicesState as jest.Mock).mockReturnValue({
|
(useScopeServicesState as jest.Mock).mockReturnValue({
|
||||||
...mockScopeServicesState,
|
...mockScopeServicesState,
|
||||||
updateNode: mockUpdateNode,
|
filterNode: mockFilterNode,
|
||||||
nodes,
|
nodes,
|
||||||
tree,
|
tree,
|
||||||
});
|
});
|
||||||
@@ -273,17 +273,17 @@ describe('useRegisterScopesActions', () => {
|
|||||||
return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1');
|
return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something');
|
expect(mockFilterNode).toHaveBeenCalledWith('scope1', 'something');
|
||||||
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
|
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not use global scope search if feature flag is off', async () => {
|
it('should not use global scope search if feature flag is off', async () => {
|
||||||
config.featureToggles.scopeSearchAllLevels = false;
|
config.featureToggles.scopeSearchAllLevels = false;
|
||||||
const mockUpdateNode = jest.fn();
|
const mockFilterNode = jest.fn();
|
||||||
// First run with empty scopes in the scopes service
|
// First run with empty scopes in the scopes service
|
||||||
(useScopeServicesState as jest.Mock).mockReturnValue({
|
(useScopeServicesState as jest.Mock).mockReturnValue({
|
||||||
...mockScopeServicesState,
|
...mockScopeServicesState,
|
||||||
updateNode: mockUpdateNode,
|
filterNode: mockFilterNode,
|
||||||
nodes,
|
nodes,
|
||||||
tree,
|
tree,
|
||||||
});
|
});
|
||||||
@@ -292,7 +292,7 @@ describe('useRegisterScopesActions', () => {
|
|||||||
return useRegisterScopesActions('something', jest.fn(), '');
|
return useRegisterScopesActions('something', jest.fn(), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something');
|
expect(mockFilterNode).toHaveBeenCalledWith('', 'something');
|
||||||
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
|
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,22 +58,22 @@ export function useRegisterScopesActions(
|
|||||||
* @param parentId
|
* @param parentId
|
||||||
*/
|
*/
|
||||||
function useScopeTreeActions(searchQuery: string, parentId?: string | null) {
|
function useScopeTreeActions(searchQuery: string, parentId?: string | null) {
|
||||||
const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
|
const { filterNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
|
||||||
|
|
||||||
// Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
|
// Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateNode('', true, '');
|
filterNode('', '');
|
||||||
resetSelection();
|
resetSelection();
|
||||||
return () => {
|
return () => {
|
||||||
resetSelection();
|
resetSelection();
|
||||||
};
|
};
|
||||||
}, [updateNode, resetSelection]);
|
}, [filterNode, resetSelection]);
|
||||||
|
|
||||||
// Load the next level of scopes when the parentId changes.
|
// Load the next level of scopes when the parentId changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!;
|
const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!;
|
||||||
updateNode(parentScopeId, true, searchQuery);
|
filterNode(parentScopeId, searchQuery);
|
||||||
}, [updateNode, searchQuery, parentId]);
|
}, [filterNode, searchQuery, parentId]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope),
|
() => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function useScopeServicesState() {
|
|||||||
const services = useScopesServices();
|
const services = useScopesServices();
|
||||||
if (!services) {
|
if (!services) {
|
||||||
return {
|
return {
|
||||||
updateNode: () => {},
|
filterNode: () => Promise.resolve(),
|
||||||
selectScope: () => {},
|
selectScope: () => {},
|
||||||
resetSelection: () => {},
|
resetSelection: () => {},
|
||||||
searchAllNodes: () => Promise.resolve([]),
|
searchAllNodes: () => Promise.resolve([]),
|
||||||
@@ -32,7 +32,7 @@ export function useScopeServicesState() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
|
const { filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
|
||||||
services.scopesSelectorService;
|
services.scopesSelectorService;
|
||||||
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
|
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
|
||||||
services.scopesSelectorService.stateObservable ?? new Observable(),
|
services.scopesSelectorService.stateObservable ?? new Observable(),
|
||||||
@@ -42,7 +42,6 @@ export function useScopeServicesState() {
|
|||||||
return {
|
return {
|
||||||
getScopeNodes,
|
getScopeNodes,
|
||||||
filterNode,
|
filterNode,
|
||||||
updateNode,
|
|
||||||
selectScope,
|
selectScope,
|
||||||
resetSelection,
|
resetSelection,
|
||||||
searchAllNodes,
|
searchAllNodes,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { SceneObject } from '@grafana/scenes';
|
import { SceneObject } from '@grafana/scenes';
|
||||||
import { Box, Icon, Sidebar, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
|
import { Box, Icon, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { isRepeatCloneOrChildOf } from '../utils/clone';
|
import { isRepeatCloneOrChildOf } from '../utils/clone';
|
||||||
import { DashboardInteractions } from '../utils/interactions';
|
import { DashboardInteractions } from '../utils/interactions';
|
||||||
@@ -85,6 +85,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
onClick={onNodeClicked}
|
onClick={onNodeClicked}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
style={{ '--depth': depth } as React.CSSProperties}
|
style={{ '--depth': depth } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
|
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
|
||||||
@@ -99,7 +100,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={cx(styles.nodeName, { [styles.nodeNameClone]: isCloned })}
|
className={cx(styles.nodeButton, { [styles.nodeButtonClone]: isCloned })}
|
||||||
onDoubleClick={outlineRename.onNameDoubleClicked}
|
onDoubleClick={outlineRename.onNameDoubleClicked}
|
||||||
data-testid={selectors.components.PanelEditor.Outline.item(instanceName)}
|
data-testid={selectors.components.PanelEditor.Outline.item(instanceName)}
|
||||||
>
|
>
|
||||||
@@ -116,10 +117,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Stack direction="row" gap={0.5} alignItems="center" grow={1}>
|
<div className={styles.nodeName}>
|
||||||
<span>{instanceName}</span>
|
<Text truncate>{instanceName}</Text>
|
||||||
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
|
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
|
||||||
</Stack>
|
</div>
|
||||||
{isCloned && (
|
{isCloned && (
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey="dashboard.outline.repeated-item">Repeat</Trans>
|
<Trans i18nKey="dashboard.outline.repeated-item">Repeat</Trans>
|
||||||
@@ -144,9 +145,20 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Text color="secondary" element="li">
|
<li
|
||||||
<Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans>
|
role="treeitem"
|
||||||
</Text>
|
aria-selected={isSelected}
|
||||||
|
className={styles.container}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
style={{ '--depth': depth + 1 } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.indentation}></div>
|
||||||
|
<Text color="secondary" italic>
|
||||||
|
<Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +209,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
}),
|
}),
|
||||||
nodeName: css({
|
nodeButton: css({
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -215,11 +227,18 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
nodeName: css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}),
|
||||||
hiddenIcon: css({
|
hiddenIcon: css({
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
marginLeft: theme.spacing(1),
|
marginLeft: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
nodeNameClone: css({
|
nodeButtonClone: css({
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
cursor: 'not-allowed',
|
cursor: 'not-allowed',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
marginBottom: theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
float: 'right',
|
float: 'right',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
}),
|
}),
|
||||||
timeControls: css({
|
timeControls: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ const defaultMatchers: LocationFieldMatchers = {
|
|||||||
geo: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.geo),
|
geo: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.geo),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* suggestions needs to run sync, and we just want to use the default matchers in that situation.
|
||||||
|
*/
|
||||||
|
export function getDefaultLocationMatchers(): LocationFieldMatchers {
|
||||||
|
return {
|
||||||
|
...defaultMatchers,
|
||||||
|
mode: FrameGeometrySourceMode.Auto,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLocationMatchers(src?: FrameGeometrySource): Promise<LocationFieldMatchers> {
|
export async function getLocationMatchers(src?: FrameGeometrySource): Promise<LocationFieldMatchers> {
|
||||||
const info: LocationFieldMatchers = {
|
const info: LocationFieldMatchers = {
|
||||||
...defaultMatchers,
|
...defaultMatchers,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CoreApp,
|
CoreApp,
|
||||||
|
FieldType,
|
||||||
getPanelDataSummary,
|
getPanelDataSummary,
|
||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
PanelDataSummary,
|
PanelDataSummary,
|
||||||
@@ -134,15 +135,15 @@ function getMessageFor(
|
|||||||
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
|
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsStringField && !dataSummary.hasStringField) {
|
if (needsStringField && !dataSummary.hasFieldType(FieldType.string)) {
|
||||||
return t('panel.panel-data-error-view.missing-value.string', 'Data is missing a string field');
|
return t('panel.panel-data-error-view.missing-value.string', 'Data is missing a string field');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsNumberField && !dataSummary.hasNumberField) {
|
if (needsNumberField && !dataSummary.hasFieldType(FieldType.number)) {
|
||||||
return t('panel.panel-data-error-view.missing-value.number', 'Data is missing a number field');
|
return t('panel.panel-data-error-view.missing-value.number', 'Data is missing a number field');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsTimeField && !dataSummary.hasTimeField) {
|
if (needsTimeField && !dataSummary.hasFieldType(FieldType.time)) {
|
||||||
return t('panel.panel-data-error-view.missing-value.time', 'Data is missing a time field');
|
return t('panel.panel-data-error-view.missing-value.time', 'Data is missing a time field');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import {
|
|||||||
LoadingState,
|
LoadingState,
|
||||||
PanelData,
|
PanelData,
|
||||||
PanelPluginMeta,
|
PanelPluginMeta,
|
||||||
toDataFrame,
|
|
||||||
PanelPluginVisualizationSuggestion,
|
PanelPluginVisualizationSuggestion,
|
||||||
|
toDataFrame,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema';
|
import {
|
||||||
|
BarGaugeDisplayMode,
|
||||||
|
BigValueColorMode,
|
||||||
|
GraphFieldConfig,
|
||||||
|
ReduceDataOptions,
|
||||||
|
StackingMode,
|
||||||
|
VizOrientation,
|
||||||
|
} from '@grafana/schema';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
|
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
|
||||||
|
|
||||||
@@ -21,6 +27,8 @@ for (const pluginId of panelsToCheckFirst) {
|
|||||||
} as PanelPluginMeta;
|
} as PanelPluginMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar'];
|
||||||
|
|
||||||
config.panels['text'] = {
|
config.panels['text'] = {
|
||||||
id: 'text',
|
id: 'text',
|
||||||
name: 'Text',
|
name: 'Text',
|
||||||
@@ -69,7 +77,10 @@ scenario('No series', (ctx) => {
|
|||||||
ctx.setData([]);
|
ctx.setData([]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel]);
|
expect(ctx.suggestions).toEqual([
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
|
expect.objectContaining({ pluginId: 'text' }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +95,7 @@ scenario('No rows', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([SuggestionName.Table]);
|
expect(ctx.suggestions).toEqual([expect.objectContaining({ pluginId: 'table' })]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,33 +111,46 @@ scenario('Single frame with time and number field', (ctx) => {
|
|||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.suggestions).toEqual([
|
expect(ctx.suggestions).toEqual([
|
||||||
expect.objectContaining({ name: SuggestionName.LineChart }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }),
|
||||||
expect.objectContaining({ name: SuggestionName.LineChartSmooth }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }),
|
||||||
expect.objectContaining({ name: SuggestionName.AreaChart }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart' }),
|
||||||
expect.objectContaining({ name: SuggestionName.LineChartGradientColorScheme }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart' }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarChart }),
|
expect.objectContaining({ pluginId: 'gauge' }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarChartGradientColorScheme }),
|
expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
|
||||||
expect.objectContaining({ name: SuggestionName.Gauge }),
|
expect.objectContaining({ pluginId: 'stat' }),
|
||||||
expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ name: SuggestionName.Stat }),
|
pluginId: 'stat',
|
||||||
expect.objectContaining({ name: SuggestionName.StatColoredBackground }),
|
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarGaugeBasic }),
|
}),
|
||||||
expect.objectContaining({ name: SuggestionName.BarGaugeLCD }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ name: SuggestionName.Table }),
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
expect.objectContaining({ pluginId: 'state-timeline' }),
|
expect.objectContaining({ pluginId: 'state-timeline' }),
|
||||||
expect.objectContaining({ name: SuggestionName.StatusHistory }),
|
expect.objectContaining({ pluginId: 'status-history' }),
|
||||||
|
expect.objectContaining({ pluginId: 'heatmap' }),
|
||||||
|
expect.objectContaining({ pluginId: 'histogram' }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Bar chart suggestion should be using timeseries panel', () => {
|
it('Bar chart suggestion should be using timeseries panel', () => {
|
||||||
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries');
|
expect(ctx.suggestions.find((x) => x.name === 'Bar chart')?.pluginId).toBe('timeseries');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Stat panels have reduce values disabled', () => {
|
it('Scalar panels should use calcs', () => {
|
||||||
for (const suggestion of ctx.suggestions) {
|
for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
|
||||||
if (suggestion.options?.reduceOptions?.values) {
|
expect(suggestion).toEqual(
|
||||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`);
|
expect.objectContaining({
|
||||||
}
|
options: expect.objectContaining({
|
||||||
|
reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -144,31 +168,46 @@ scenario('Single frame with time 2 number fields', (ctx) => {
|
|||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.suggestions).toEqual([
|
expect(ctx.suggestions).toEqual([
|
||||||
expect.objectContaining({ name: SuggestionName.LineChart }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }),
|
||||||
expect.objectContaining({ name: SuggestionName.LineChartSmooth }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }),
|
||||||
expect.objectContaining({ name: SuggestionName.AreaChartStacked }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked' }),
|
||||||
expect.objectContaining({ name: SuggestionName.AreaChartStackedPercent }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked by percentage' }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarChartStacked }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked' }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarChartStackedPercent }),
|
expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked by percentage' }),
|
||||||
expect.objectContaining({ name: SuggestionName.Gauge }),
|
expect.objectContaining({ pluginId: 'gauge' }),
|
||||||
expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }),
|
expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
|
||||||
expect.objectContaining({ name: SuggestionName.Stat }),
|
expect.objectContaining({ pluginId: 'stat' }),
|
||||||
expect.objectContaining({ name: SuggestionName.StatColoredBackground }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ name: SuggestionName.PieChart }),
|
pluginId: 'stat',
|
||||||
expect.objectContaining({ name: SuggestionName.PieChartDonut }),
|
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
|
||||||
expect.objectContaining({ name: SuggestionName.BarGaugeBasic }),
|
}),
|
||||||
expect.objectContaining({ name: SuggestionName.BarGaugeLCD }),
|
expect.objectContaining({ pluginId: 'piechart' }),
|
||||||
expect.objectContaining({ name: SuggestionName.Table }),
|
expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
expect.objectContaining({ pluginId: 'state-timeline' }),
|
expect.objectContaining({ pluginId: 'state-timeline' }),
|
||||||
expect.objectContaining({ name: SuggestionName.StatusHistory }),
|
expect.objectContaining({ pluginId: 'status-history' }),
|
||||||
|
expect.objectContaining({ pluginId: 'heatmap' }),
|
||||||
|
expect.objectContaining({ pluginId: 'histogram' }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Stat panels have reduceOptions.values disabled', () => {
|
it('Scalar panels should use calcs', () => {
|
||||||
for (const suggestion of ctx.suggestions) {
|
for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
|
||||||
if (suggestion.options?.reduceOptions?.values) {
|
expect(suggestion).toEqual(
|
||||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`);
|
expect.objectContaining({
|
||||||
}
|
options: expect.objectContaining({
|
||||||
|
reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -184,7 +223,7 @@ scenario('Single time series with 100 data points', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should not suggest bar chart', () => {
|
it('should not suggest bar chart', () => {
|
||||||
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined);
|
expect(ctx.suggestions.find((x) => x.name === 'Bar chart')).toBe(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,26 +274,42 @@ scenario('Single frame with string and number field', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([
|
expect(ctx.suggestions).toEqual([
|
||||||
SuggestionName.BarChart,
|
expect.objectContaining({ pluginId: 'piechart' }),
|
||||||
SuggestionName.BarChartHorizontal,
|
expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
|
||||||
SuggestionName.Gauge,
|
expect.objectContaining({ pluginId: 'barchart' }),
|
||||||
SuggestionName.GaugeNoThresholds,
|
expect.objectContaining({
|
||||||
SuggestionName.Stat,
|
pluginId: 'barchart',
|
||||||
SuggestionName.StatColoredBackground,
|
options: expect.objectContaining({ orientation: VizOrientation.Horizontal }),
|
||||||
SuggestionName.PieChart,
|
}),
|
||||||
SuggestionName.PieChartDonut,
|
expect.objectContaining({ pluginId: 'gauge' }),
|
||||||
SuggestionName.BarGaugeBasic,
|
expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
|
||||||
SuggestionName.BarGaugeLCD,
|
expect.objectContaining({ pluginId: 'stat' }),
|
||||||
SuggestionName.Table,
|
expect.objectContaining({
|
||||||
|
pluginId: 'stat',
|
||||||
|
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
|
expect.objectContaining({ pluginId: 'histogram' }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => {
|
it('Scalar panels should contain raw values', () => {
|
||||||
for (const suggestion of ctx.suggestions) {
|
for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) {
|
||||||
if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) {
|
expect(suggestion).toEqual(
|
||||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`);
|
expect.objectContaining({
|
||||||
}
|
options: expect.objectContaining({ reduceOptions: expect.objectContaining({ values: true, calcs: [] }) }),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -271,22 +326,48 @@ scenario('Single frame with string and 2 number field', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([
|
expect(ctx.suggestions).toEqual([
|
||||||
SuggestionName.BarChart,
|
expect.objectContaining({ pluginId: 'barchart' }),
|
||||||
SuggestionName.BarChartStacked,
|
expect.objectContaining({
|
||||||
SuggestionName.BarChartStackedPercent,
|
pluginId: 'barchart',
|
||||||
SuggestionName.BarChartHorizontal,
|
options: expect.objectContaining({ stacking: StackingMode.Normal }),
|
||||||
SuggestionName.BarChartHorizontalStacked,
|
}),
|
||||||
SuggestionName.BarChartHorizontalStackedPercent,
|
expect.objectContaining({
|
||||||
SuggestionName.Gauge,
|
pluginId: 'barchart',
|
||||||
SuggestionName.GaugeNoThresholds,
|
options: expect.objectContaining({ stacking: StackingMode.Percent }),
|
||||||
SuggestionName.Stat,
|
}),
|
||||||
SuggestionName.StatColoredBackground,
|
|
||||||
SuggestionName.PieChart,
|
expect.objectContaining({
|
||||||
SuggestionName.PieChartDonut,
|
pluginId: 'barchart',
|
||||||
SuggestionName.BarGaugeBasic,
|
options: expect.objectContaining({ orientation: VizOrientation.Horizontal }),
|
||||||
SuggestionName.BarGaugeLCD,
|
}),
|
||||||
SuggestionName.Table,
|
expect.objectContaining({
|
||||||
|
pluginId: 'barchart',
|
||||||
|
options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Normal }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'barchart',
|
||||||
|
options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Percent }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'gauge' }),
|
||||||
|
expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }),
|
||||||
|
expect.objectContaining({ pluginId: 'stat' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'stat',
|
||||||
|
options: expect.objectContaining({ colorMode: BigValueColorMode.Background }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'piechart' }),
|
||||||
|
expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: 'bargauge',
|
||||||
|
options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
|
expect.objectContaining({ pluginId: 'histogram' }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -299,11 +380,14 @@ scenario('Single frame with only string field', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.Table]);
|
expect(ctx.suggestions).toEqual([
|
||||||
|
expect.objectContaining({ pluginId: 'stat' }),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Stat panels have reduceOptions.fields set to show all fields', () => {
|
it('Stat panels have reduceOptions.fields set to show all fields', () => {
|
||||||
for (const suggestion of ctx.suggestions) {
|
for (const suggestion of ctx.suggestions.filter((s) => s.pluginId === 'stat')) {
|
||||||
if (suggestion.options?.reduceOptions) {
|
if (suggestion.options?.reduceOptions) {
|
||||||
expect(suggestion.options.reduceOptions.fields).toBe('/.*/');
|
expect(suggestion.options.reduceOptions.fields).toBe('/.*/');
|
||||||
}
|
}
|
||||||
@@ -333,7 +417,10 @@ scenario('Given default loki logs data', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return correct suggestions', () => {
|
it('should return correct suggestions', () => {
|
||||||
expect(ctx.names()).toEqual([SuggestionName.Logs, SuggestionName.Table]);
|
expect(ctx.suggestions).toEqual([
|
||||||
|
expect.objectContaining({ pluginId: 'logs' }),
|
||||||
|
expect.objectContaining({ pluginId: 'table' }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -356,7 +443,7 @@ scenario('Given a preferredVisualisationType', (ctx) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
it('should return the preferred visualization first', () => {
|
it('should return the preferred visualization first', () => {
|
||||||
expect(ctx.names()[0]).toEqual(SuggestionName.Table);
|
expect(ctx.suggestions[0]).toEqual(expect.objectContaining({ pluginId: 'table' }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
VisualizationSuggestionsBuilder,
|
VisualizationSuggestionsBuilder,
|
||||||
PanelModel,
|
PanelModel,
|
||||||
VisualizationSuggestionScore,
|
VisualizationSuggestionScore,
|
||||||
|
PreferredVisualisationType,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
|
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
|
||||||
@@ -23,8 +24,26 @@ export const panelsToCheckFirst = [
|
|||||||
'flamegraph',
|
'flamegraph',
|
||||||
'traces',
|
'traces',
|
||||||
'nodeGraph',
|
'nodeGraph',
|
||||||
|
'heatmap',
|
||||||
|
'histogram',
|
||||||
|
'geomap',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* some of the PreferredVisualisationTypes do not match the panel plugin ids, so we have to map them. d'oh.
|
||||||
|
*/
|
||||||
|
const PLUGIN_ID_TO_PREFERRED_VIZ_TYPE: Record<string, PreferredVisualisationType> = {
|
||||||
|
traces: 'trace',
|
||||||
|
timeseries: 'graph',
|
||||||
|
table: 'table',
|
||||||
|
logs: 'logs',
|
||||||
|
nodeGraph: 'nodeGraph',
|
||||||
|
flamegraph: 'flamegraph',
|
||||||
|
};
|
||||||
|
const mapPreferredVisualisationTypeToPlugin = (type: string): PreferredVisualisationType | undefined => {
|
||||||
|
return PLUGIN_ID_TO_PREFERRED_VIZ_TYPE[type];
|
||||||
|
};
|
||||||
|
|
||||||
export async function getAllSuggestions(
|
export async function getAllSuggestions(
|
||||||
data?: PanelData,
|
data?: PanelData,
|
||||||
panel?: PanelModel
|
panel?: PanelModel
|
||||||
@@ -61,13 +80,13 @@ export async function getAllSuggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
if (builder.dataSummary.preferredVisualisationType) {
|
const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||||
if (a.pluginId === builder.dataSummary.preferredVisualisationType) {
|
if (mappedA && builder.dataSummary.hasPreferredVisualisationType(mappedA)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (b.pluginId === builder.dataSummary.preferredVisualisationType) {
|
const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId);
|
||||||
return 1;
|
if (mappedB && builder.dataSummary.hasPreferredVisualisationType(mappedB)) {
|
||||||
}
|
return 1;
|
||||||
}
|
}
|
||||||
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
|
return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { PanelData, PanelDataSummary } from '@grafana/data';
|
import {
|
||||||
|
DataFrameType,
|
||||||
|
PanelData,
|
||||||
|
PanelDataSummary,
|
||||||
|
VisualizationSuggestion,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { ReduceDataOptions } from '@grafana/schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -10,6 +17,42 @@ export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) =
|
|||||||
return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined);
|
return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
* for panel plugins which render "scalar" data (stat, gauge, etc), this helper provides default reduce options
|
||||||
|
* depending on whether deaggregation is likely needed.
|
||||||
|
* @param suggestion the suggestion to modify
|
||||||
|
* @param panelDataSummary the panel data summary to use for scoring
|
||||||
|
* @param shouldUseRawValues if true, reduceOptions will be set to use raw values,
|
||||||
|
* otherwise a calcs will be used with the default value of `lastNotNull`.
|
||||||
|
*/
|
||||||
|
export function defaultNumericVizOptions(
|
||||||
|
suggestion: VisualizationSuggestion<{ reduceOptions?: ReduceDataOptions }>,
|
||||||
|
panelDataSummary: PanelDataSummary,
|
||||||
|
shouldUseRawValues: boolean
|
||||||
|
): VisualizationSuggestion {
|
||||||
|
suggestion.score =
|
||||||
|
(suggestion.score ??
|
||||||
|
(panelDataSummary.hasDataFrameType(DataFrameType.NumericLong) ||
|
||||||
|
panelDataSummary.hasDataFrameType(DataFrameType.NumericWide) ||
|
||||||
|
panelDataSummary.hasDataFrameType(DataFrameType.NumericMulti)))
|
||||||
|
? VisualizationSuggestionScore.Good
|
||||||
|
: VisualizationSuggestionScore.OK;
|
||||||
|
suggestion.options = suggestion.options ?? {};
|
||||||
|
suggestion.options.reduceOptions =
|
||||||
|
suggestion.options.reduceOptions ??
|
||||||
|
(shouldUseRawValues
|
||||||
|
? {
|
||||||
|
values: true,
|
||||||
|
calcs: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
values: false,
|
||||||
|
calcs: ['lastNotNull'],
|
||||||
|
});
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
* Checks if the panel has data
|
* Checks if the panel has data
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Trans } from '@grafana/i18n';
|
|||||||
import { LinkButton, Stack, Text, TextLink } from '@grafana/ui';
|
import { LinkButton, Stack, Text, TextLink } from '@grafana/ui';
|
||||||
import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
|
import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||||
|
|
||||||
import { getRepoHref } from '../utils/git';
|
import { getRepoHrefForProvider } from '../utils/git';
|
||||||
|
|
||||||
type RepositoryLinkProps = {
|
type RepositoryLinkProps = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -19,7 +19,7 @@ export function RepositoryLink({ name, jobType }: RepositoryLinkProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoHref = getRepoHref(repo.spec?.github);
|
const repoHref = getRepoHrefForProvider(repo.spec);
|
||||||
|
|
||||||
if (jobType === 'sync') {
|
if (jobType === 'sync') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+6
-16
@@ -2,12 +2,13 @@ import { ReactNode } from 'react';
|
|||||||
|
|
||||||
import { t, Trans } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { Stack, Text, TextLink, Icon, Card, LinkButton, Badge } from '@grafana/ui';
|
import { Badge, Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
|
||||||
import { Repository, ResourceCount } from 'app/api/clients/provisioning/v0alpha1';
|
import { Repository, ResourceCount } from 'app/api/clients/provisioning/v0alpha1';
|
||||||
|
|
||||||
import { RepoIcon } from '../Shared/RepoIcon';
|
import { RepoIcon } from '../Shared/RepoIcon';
|
||||||
import { StatusBadge } from '../Shared/StatusBadge';
|
import { StatusBadge } from '../Shared/StatusBadge';
|
||||||
import { PROVISIONING_URL } from '../constants';
|
import { PROVISIONING_URL } from '../constants';
|
||||||
|
import { getRepoHrefForProvider } from '../utils/git';
|
||||||
import { getIsReadOnlyWorkflows } from '../utils/repository';
|
import { getIsReadOnlyWorkflows } from '../utils/repository';
|
||||||
|
|
||||||
import { SyncRepository } from './SyncRepository';
|
import { SyncRepository } from './SyncRepository';
|
||||||
@@ -16,7 +17,7 @@ interface Props {
|
|||||||
repository: Repository;
|
repository: Repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepositoryCard({ repository }: Props) {
|
export function RepositoryListItem({ repository }: Props) {
|
||||||
const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows);
|
const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows);
|
||||||
const { metadata, spec, status } = repository;
|
const { metadata, spec, status } = repository;
|
||||||
const name = metadata?.name ?? '';
|
const name = metadata?.name ?? '';
|
||||||
@@ -27,24 +28,13 @@ export function RepositoryCard({ repository }: Props) {
|
|||||||
if (spec?.type === 'github') {
|
if (spec?.type === 'github') {
|
||||||
const { url = '', branch } = spec.github ?? {};
|
const { url = '', branch } = spec.github ?? {};
|
||||||
const branchUrl = branch ? `${url}/tree/${branch}` : url;
|
const branchUrl = branch ? `${url}/tree/${branch}` : url;
|
||||||
|
const href = getRepoHrefForProvider(spec) || branchUrl;
|
||||||
|
|
||||||
meta.push(
|
meta.push(
|
||||||
<TextLink key="link" external href={branchUrl}>
|
<TextLink key="link" external href={href}>
|
||||||
{branchUrl}
|
{href.split('/').slice(3).join('/')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status?.webhook?.id) {
|
|
||||||
const webhookUrl = `${url}/settings/hooks/${status.webhook.id}`;
|
|
||||||
meta.push(
|
|
||||||
<Stack gap={1} direction="row" alignItems="center">
|
|
||||||
<TextLink key="webhook" href={webhookUrl}>
|
|
||||||
<Trans i18nKey="provisioning.repository-card.get-repository-meta.webhook">Webhook</Trans>
|
|
||||||
</TextLink>
|
|
||||||
<Icon name="check" className="text-success" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (spec?.type === 'local') {
|
} else if (spec?.type === 'local') {
|
||||||
meta.push(
|
meta.push(
|
||||||
<Text variant="bodySmall" key="path">
|
<Text variant="bodySmall" key="path">
|
||||||
@@ -4,7 +4,7 @@ import { t, Trans } from '@grafana/i18n';
|
|||||||
import { Alert, Box, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui';
|
import { Alert, Box, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui';
|
||||||
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
|
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
|
||||||
|
|
||||||
import { RepositoryCard } from '../Repository/RepositoryCard';
|
import { RepositoryListItem } from '../Repository/RepositoryListItem';
|
||||||
import { useResourceStats } from '../Wizard/hooks/useResourceStats';
|
import { useResourceStats } from '../Wizard/hooks/useResourceStats';
|
||||||
import { UPGRADE_URL } from '../constants';
|
import { UPGRADE_URL } from '../constants';
|
||||||
import { useIsProvisionedInstance } from '../hooks/useIsProvisionedInstance';
|
import { useIsProvisionedInstance } from '../hooks/useIsProvisionedInstance';
|
||||||
@@ -89,7 +89,7 @@ export function RepositoryList({ items }: Props) {
|
|||||||
)}
|
)}
|
||||||
<Stack direction={'column'} gap={2}>
|
<Stack direction={'column'} gap={2}>
|
||||||
{filteredItems.length ? (
|
{filteredItems.length ? (
|
||||||
filteredItems.map((item) => <RepositoryCard key={item.metadata?.name} repository={item} />)
|
filteredItems.map((item) => <RepositoryListItem key={item.metadata?.name} repository={item} />)
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="not-found"
|
variant="not-found"
|
||||||
|
|||||||
@@ -107,9 +107,14 @@ export function PreviewBannerViewPR({ prParam, isNewPr, behindBranch, repoUrl, b
|
|||||||
{/* when repo type is not local, we show branch information */}
|
{/* when repo type is not local, we show branch information */}
|
||||||
{showBranchInfo(repoType, branchInfo) && (
|
{showBranchInfo(repoType, branchInfo) && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Trans i18nKey="provisioned-resource-preview-banner.preview-banner.branch-text">branch: </Trans>
|
<Trans i18nKey="provisioned-resource-preview-banner.preview-banner.branch-text">branch:</Trans>{' '}
|
||||||
<TextLink href={getBranchUrl(repoBaseUrl!, targetBranch!, repoType)}>{targetBranch}</TextLink> {'\u2192'}{' '}
|
<TextLink href={getBranchUrl(repoBaseUrl!, targetBranch!, repoType)} external>
|
||||||
<TextLink href={getBranchUrl(repoBaseUrl!, configuredBranch!, repoType)}>{configuredBranch}</TextLink>
|
{targetBranch}
|
||||||
|
</TextLink>{' '}
|
||||||
|
{'\u2192'}{' '}
|
||||||
|
<TextLink href={getBranchUrl(repoBaseUrl!, configuredBranch!, repoType)} external>
|
||||||
|
{configuredBranch}
|
||||||
|
</TextLink>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export const usePullRequestParam = () => {
|
|||||||
const repoType = params.get('repo_type');
|
const repoType = params.get('repo_type');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prURL: prParam ? textUtil.sanitizeUrl(prParam) : undefined,
|
prURL: prParam ? textUtil.sanitizeUrl(decodeURIComponent(prParam)) : undefined,
|
||||||
newPrURL: newPrParam ? textUtil.sanitizeUrl(newPrParam) : undefined,
|
newPrURL: newPrParam ? textUtil.sanitizeUrl(decodeURIComponent(newPrParam)) : undefined,
|
||||||
repoURL: repoUrl ? textUtil.sanitizeUrl(repoUrl) : undefined,
|
repoURL: repoUrl ? textUtil.sanitizeUrl(decodeURIComponent(repoUrl)) : undefined,
|
||||||
repoType: repoType ? textUtil.sanitizeUrl(repoType) : undefined,
|
repoType: repoType ? textUtil.sanitizeUrl(decodeURIComponent(repoType)) : undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,41 +27,81 @@ export const getRepoHref = (github?: RepositorySpec['github']) => {
|
|||||||
return `${github.url}/tree/${github.branch}`;
|
return `${github.url}/tree/${github.branch}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove leading and trailing slashes from a string.
|
||||||
|
const stripSlashes = (s: string) => s.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
// Split a path into segments and URL-encode each segment.
|
||||||
|
// Ensures the final URL remains valid for all providers (GitHub, GitLab, etc.).
|
||||||
|
const splitAndEncode = (s: string) => stripSlashes(s).split('/').map(encodeURIComponent);
|
||||||
|
|
||||||
|
type BuildRepoUrlParams = {
|
||||||
|
baseUrl?: string;
|
||||||
|
branch?: string | null;
|
||||||
|
providerSegments: string[];
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRepoUrl = ({ baseUrl, branch, providerSegments, path }: BuildRepoUrlParams) => {
|
||||||
|
if (!baseUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize base URL: trim whitespace + remove trailing slashes.
|
||||||
|
const cleanBase = stripSlashes(baseUrl.trim());
|
||||||
|
const cleanBranch = branch?.trim() || undefined;
|
||||||
|
|
||||||
|
// Start composing URL parts:
|
||||||
|
// base URL + provider-specific segments (e.g., "tree", "blob", etc.)
|
||||||
|
const parts = [cleanBase, ...providerSegments];
|
||||||
|
|
||||||
|
// Append the branch name if present.
|
||||||
|
if (cleanBranch) {
|
||||||
|
parts.push(cleanBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append encoded path segments if provided.
|
||||||
|
// This ensures nested files like "src/utils/index.ts" produce safe URLs.
|
||||||
|
if (path) {
|
||||||
|
parts.push(...splitAndEncode(path.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
export const getRepoHrefForProvider = (spec?: RepositorySpec) => {
|
export const getRepoHrefForProvider = (spec?: RepositorySpec) => {
|
||||||
if (!spec || !spec.type) {
|
if (!spec || !spec.type) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (spec.type) {
|
switch (spec.type) {
|
||||||
case 'github': {
|
case 'github':
|
||||||
const url = spec.github?.url;
|
return buildRepoUrl({
|
||||||
const branch = spec.github?.branch;
|
baseUrl: spec.github?.url,
|
||||||
if (!url) {
|
branch: spec.github?.branch,
|
||||||
return undefined;
|
providerSegments: ['tree'],
|
||||||
}
|
path: spec.github?.path,
|
||||||
return branch ? `${url}/tree/${branch}` : url;
|
});
|
||||||
}
|
case 'gitlab':
|
||||||
|
return buildRepoUrl({
|
||||||
case 'gitlab': {
|
baseUrl: spec.gitlab?.url,
|
||||||
const url = spec.gitlab?.url;
|
branch: spec.gitlab?.branch,
|
||||||
const branch = spec.gitlab?.branch;
|
providerSegments: ['-', 'tree'],
|
||||||
if (!url) {
|
path: spec.gitlab?.path,
|
||||||
return undefined;
|
});
|
||||||
}
|
case 'bitbucket':
|
||||||
return branch ? `${url}/-/tree/${branch}` : url;
|
return buildRepoUrl({
|
||||||
}
|
baseUrl: spec.bitbucket?.url,
|
||||||
case 'bitbucket': {
|
branch: spec.bitbucket?.branch,
|
||||||
const url = spec.bitbucket?.url;
|
providerSegments: ['src'],
|
||||||
const branch = spec.bitbucket?.branch;
|
path: spec.bitbucket?.path,
|
||||||
if (!url) {
|
});
|
||||||
return undefined;
|
case 'git':
|
||||||
}
|
return buildRepoUrl({
|
||||||
return branch ? `${url}/src/${branch}` : url;
|
baseUrl: spec.git?.url,
|
||||||
}
|
branch: spec.git?.branch,
|
||||||
case 'git': {
|
providerSegments: ['tree'],
|
||||||
// Return a generic URL for pure git repositories
|
path: spec.git?.path,
|
||||||
return spec.git?.url;
|
});
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,162 +107,9 @@ describe('ScopesSelectorService', () => {
|
|||||||
service = new ScopesSelectorService(apiClient, dashboardsService, store);
|
service = new ScopesSelectorService(apiClient, dashboardsService, store);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateNode', () => {
|
|
||||||
it('should update node and fetch children when expanded', async () => {
|
|
||||||
await service.updateNode('', true, '');
|
|
||||||
expect(service.state.nodes['test-scope-node']).toEqual(mockNode);
|
|
||||||
expect(service.state.tree).toMatchObject({
|
|
||||||
children: { 'test-scope-node': { expanded: false, scopeNodeId: 'test-scope-node' } },
|
|
||||||
expanded: true,
|
|
||||||
query: '',
|
|
||||||
scopeNodeId: '',
|
|
||||||
});
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should update node query and fetch children when query changes', async () => {
|
|
||||||
await service.updateNode('', true, ''); // Expand first
|
|
||||||
// Simulate a change in the query
|
|
||||||
await service.updateNode('', true, 'new-qu');
|
|
||||||
await service.updateNode('', true, 'new-query');
|
|
||||||
expect(service.state.tree).toMatchObject({
|
|
||||||
children: {},
|
|
||||||
expanded: true,
|
|
||||||
query: 'new-query',
|
|
||||||
scopeNodeId: '',
|
|
||||||
});
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-query' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fetch children when node is collapsed and query is unchanged', async () => {
|
|
||||||
// First expand the node
|
|
||||||
await service.updateNode('', true, '');
|
|
||||||
// Then collapse it
|
|
||||||
await service.updateNode('', false, '');
|
|
||||||
// Only the first expansion should trigger fetchNodes
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should clear query on first expansion but keep it when filtering within populated node', async () => {
|
|
||||||
const mockChildNode: ScopeNode = {
|
|
||||||
metadata: { name: 'child-node' },
|
|
||||||
spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' },
|
|
||||||
};
|
|
||||||
|
|
||||||
apiClient.fetchNodes.mockResolvedValue([mockChildNode]);
|
|
||||||
|
|
||||||
// Scenario 1: First expansion (no children yet) - clear query for unfiltered view
|
|
||||||
await service.updateNode('', true, 'search-query');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
||||||
|
|
||||||
// Parent query should be cleared and child nodes should have no query (first expansion)
|
|
||||||
expect(service.state.tree?.query).toBe('');
|
|
||||||
let childTreeNode = service.state.tree?.children?.['child-node'];
|
|
||||||
expect(childTreeNode?.query).toBe('');
|
|
||||||
|
|
||||||
// Scenario 2: Filtering within node that already has children
|
|
||||||
await service.updateNode('', true, 'new-search');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-search' });
|
|
||||||
|
|
||||||
// Parent and child nodes should have the filter query (filtering within existing children)
|
|
||||||
expect(service.state.tree?.query).toBe('new-search');
|
|
||||||
childTreeNode = service.state.tree?.children?.['child-node'];
|
|
||||||
expect(childTreeNode?.query).toBe('new-search');
|
|
||||||
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should always reset query on any expansion', async () => {
|
|
||||||
const mockChildNode: ScopeNode = {
|
|
||||||
metadata: { name: 'child-node' },
|
|
||||||
spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' },
|
|
||||||
};
|
|
||||||
|
|
||||||
apiClient.fetchNodes.mockResolvedValue([mockChildNode]);
|
|
||||||
|
|
||||||
// First expansion with any query should reset parent query and not pass query to API
|
|
||||||
await service.updateNode('', true, 'some-search-query');
|
|
||||||
|
|
||||||
// Verify query is reset and API called without query for first expansion
|
|
||||||
expect(service.state.tree?.query).toBe('');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
||||||
expect(service.state.tree?.children?.['child-node']?.query).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should handle query reset correctly for nested levels beyond root', async () => {
|
|
||||||
// Set up mock nodes for multi-level hierarchy
|
|
||||||
const mockParentNode: ScopeNode = {
|
|
||||||
metadata: { name: 'parent-container' },
|
|
||||||
spec: { linkId: '', linkType: 'scope', parentName: '', nodeType: 'container', title: 'Parent Container' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockChildNode: ScopeNode = {
|
|
||||||
metadata: { name: 'child-container' },
|
|
||||||
spec: {
|
|
||||||
linkId: '',
|
|
||||||
linkType: 'scope',
|
|
||||||
parentName: 'parent-container',
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Child Container',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockGrandchildNode: ScopeNode = {
|
|
||||||
metadata: { name: 'grandchild-leaf' },
|
|
||||||
spec: {
|
|
||||||
linkId: 'leaf-scope',
|
|
||||||
linkType: 'scope',
|
|
||||||
parentName: 'child-container',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'Grandchild Leaf',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock different responses for different parent nodes
|
|
||||||
apiClient.fetchNodes.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
|
|
||||||
if (options.parent === '') {
|
|
||||||
return Promise.resolve([mockParentNode]);
|
|
||||||
} else if (options.parent === 'parent-container') {
|
|
||||||
return Promise.resolve([mockChildNode]);
|
|
||||||
} else if (options.parent === 'child-container') {
|
|
||||||
return Promise.resolve([mockGrandchildNode]);
|
|
||||||
}
|
|
||||||
return Promise.resolve([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Expand root node with search query
|
|
||||||
await service.updateNode('', true, 'search-query');
|
|
||||||
|
|
||||||
// Root should have query reset, API called without query
|
|
||||||
expect(service.state.tree?.query).toBe('');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
||||||
expect(service.state.tree?.children?.['parent-container']?.query).toBe('');
|
|
||||||
|
|
||||||
// Step 2: Expand first-level child with search query
|
|
||||||
await service.updateNode('parent-container', true, 'open-search-query');
|
|
||||||
|
|
||||||
// First-level child should have query reset, API called without query
|
|
||||||
const parentContainer = service.state.tree?.children?.['parent-container'];
|
|
||||||
expect(parentContainer?.query).toBe('');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: undefined });
|
|
||||||
expect(parentContainer?.children?.['child-container']?.query).toBe('');
|
|
||||||
|
|
||||||
// Step 3: Now filter within the first-level child (second call to same node)
|
|
||||||
await service.updateNode('parent-container', true, 'filter-search');
|
|
||||||
|
|
||||||
// Now both parent and children should show the filter query since we're filtering within existing children
|
|
||||||
const newParentContainer = service.state.tree?.children?.['parent-container'];
|
|
||||||
expect(newParentContainer?.query).toBe('filter-search');
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: 'filter-search' });
|
|
||||||
expect(newParentContainer?.children?.['child-container']?.query).toBe('filter-search');
|
|
||||||
|
|
||||||
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('selectScope and deselectScope', () => {
|
describe('selectScope and deselectScope', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select a scope', async () => {
|
it('should select a scope', async () => {
|
||||||
@@ -311,7 +158,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
|
|
||||||
it('should set parent node for recent scopes', async () => {
|
it('should set parent node for recent scopes', async () => {
|
||||||
// Load mock node
|
// Load mock node
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
|
|
||||||
await service.changeScopes(['test-scope'], 'test-scope-node');
|
await service.changeScopes(['test-scope'], 'test-scope-node');
|
||||||
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope', parentNodeId: 'test-scope-node' }]);
|
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope', parentNodeId: 'test-scope-node' }]);
|
||||||
@@ -363,7 +210,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
|
|
||||||
describe('closeAndApply', () => {
|
describe('closeAndApply', () => {
|
||||||
it('should close the selector and apply the selected scopes', async () => {
|
it('should close the selector and apply the selected scopes', async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.closeAndApply();
|
await service.closeAndApply();
|
||||||
expect(service.state.opened).toBe(false);
|
expect(service.state.opened).toBe(false);
|
||||||
@@ -373,6 +220,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
|
|
||||||
describe('apply', () => {
|
describe('apply', () => {
|
||||||
it('should apply the selected scopes without closing the selector', async () => {
|
it('should apply the selected scopes without closing the selector', async () => {
|
||||||
|
await service.filterNode('', '');
|
||||||
await service.open();
|
await service.open();
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.apply();
|
await service.apply();
|
||||||
@@ -391,7 +239,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
|
|
||||||
describe('removeAllScopes', () => {
|
describe('removeAllScopes', () => {
|
||||||
it('should remove all selected and applied scopes', async () => {
|
it('should remove all selected and applied scopes', async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.apply();
|
await service.apply();
|
||||||
await service.removeAllScopes();
|
await service.removeAllScopes();
|
||||||
@@ -399,7 +247,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear navigation scope when removing all scopes', async () => {
|
it('should clear navigation scope when removing all scopes', async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.apply();
|
await service.apply();
|
||||||
await service.removeAllScopes();
|
await service.removeAllScopes();
|
||||||
@@ -429,7 +277,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
|
|
||||||
describe('getRecentScopes', () => {
|
describe('getRecentScopes', () => {
|
||||||
it('should parse and filter scopes', async () => {
|
it('should parse and filter scopes', async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.apply();
|
await service.apply();
|
||||||
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScope2], [mockScope]]);
|
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScope2], [mockScope]]);
|
||||||
@@ -439,7 +287,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should work with old version', async () => {
|
it('should work with old version', async () => {
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
await service.apply();
|
await service.apply();
|
||||||
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
|
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
|
||||||
@@ -615,9 +463,347 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toggleExpandedNode', () => {
|
||||||
|
const expandableNode: ScopeNode = {
|
||||||
|
metadata: { name: 'expandable-node' },
|
||||||
|
spec: {
|
||||||
|
linkId: '',
|
||||||
|
linkType: undefined,
|
||||||
|
parentName: '',
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Expandable Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const childNode: ScopeNode = {
|
||||||
|
metadata: { name: 'child-node' },
|
||||||
|
spec: {
|
||||||
|
linkId: 'child-scope',
|
||||||
|
linkType: 'scope',
|
||||||
|
parentName: 'expandable-node',
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Child Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const leafNode: ScopeNode = {
|
||||||
|
metadata: { name: 'leaf-node' },
|
||||||
|
spec: {
|
||||||
|
linkId: 'leaf-scope',
|
||||||
|
linkType: 'scope',
|
||||||
|
parentName: '',
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Leaf Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock fetchNodes to return different nodes based on parent
|
||||||
|
apiClient.fetchNodes = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
|
||||||
|
if (options.parent === '') {
|
||||||
|
return [expandableNode, leafNode];
|
||||||
|
} else if (options.parent === 'expandable-node') {
|
||||||
|
return [childNode];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load root nodes
|
||||||
|
await service.filterNode('', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand a collapsed node and load its children', async () => {
|
||||||
|
// Node should start collapsed
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false);
|
||||||
|
|
||||||
|
// Expand the node
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
|
||||||
|
// Node should now be expanded
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true);
|
||||||
|
// Children should be loaded
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.children).toBeDefined();
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.children?.['child-node']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should collapse an expanded node', async () => {
|
||||||
|
// First expand the node
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true);
|
||||||
|
|
||||||
|
// Now collapse it
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset query to empty string when toggling', async () => {
|
||||||
|
// First filter with a query
|
||||||
|
await service.filterNode('expandable-node', 'test-query');
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.query).toBe('test-query');
|
||||||
|
|
||||||
|
// Toggle the node
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
|
||||||
|
// Query should be reset
|
||||||
|
expect(service.state.tree?.children?.['expandable-node']?.query).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when node not found in tree', async () => {
|
||||||
|
await expect(service.toggleExpandedNode('non-existent-node')).rejects.toThrow(
|
||||||
|
'Node non-existent-node not found in tree'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when trying to toggle a non-expandable node', async () => {
|
||||||
|
await expect(service.toggleExpandedNode('leaf-node')).rejects.toThrow(
|
||||||
|
'Trying to expand node at id leaf-node that is not expandable'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reload parent children when collapsing', async () => {
|
||||||
|
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
|
||||||
|
|
||||||
|
// Expand then collapse
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
fetchNodesSpy.mockClear();
|
||||||
|
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
|
||||||
|
// Should reload parent's (root) children
|
||||||
|
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reload parent children with parent query when collapsing', async () => {
|
||||||
|
// First filter the root with a query
|
||||||
|
await service.filterNode('', 'parent-query');
|
||||||
|
|
||||||
|
// Expand a node
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
|
||||||
|
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
|
||||||
|
|
||||||
|
// Collapse the node
|
||||||
|
await service.toggleExpandedNode('expandable-node');
|
||||||
|
|
||||||
|
// Should reload parent's children with parent's query
|
||||||
|
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: 'parent-query' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterNode', () => {
|
||||||
|
const containerNode: ScopeNode = {
|
||||||
|
metadata: { name: 'container-node' },
|
||||||
|
spec: {
|
||||||
|
linkId: '',
|
||||||
|
linkType: undefined,
|
||||||
|
parentName: '',
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Container Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredChild: ScopeNode = {
|
||||||
|
metadata: { name: 'filtered-child' },
|
||||||
|
spec: {
|
||||||
|
linkId: 'filtered-scope',
|
||||||
|
linkType: 'scope',
|
||||||
|
parentName: 'container-node',
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Filtered Child',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const leafNode: ScopeNode = {
|
||||||
|
metadata: { name: 'leaf-node-2' },
|
||||||
|
spec: {
|
||||||
|
linkId: 'leaf-scope-2',
|
||||||
|
linkType: 'scope',
|
||||||
|
parentName: '',
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Leaf Node 2',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock fetchNodes to return different nodes based on query
|
||||||
|
apiClient.fetchNodes = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
|
||||||
|
if (options.parent === '' && !options.query) {
|
||||||
|
return [containerNode, leafNode];
|
||||||
|
} else if (options.parent === 'container-node' && options.query === 'test-filter') {
|
||||||
|
return [filteredChild];
|
||||||
|
} else if (options.parent === 'container-node' && !options.query) {
|
||||||
|
return [filteredChild];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load root nodes
|
||||||
|
await service.filterNode('', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter node with non-empty query', async () => {
|
||||||
|
await service.filterNode('container-node', 'test-filter');
|
||||||
|
|
||||||
|
// Node should be expanded
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true);
|
||||||
|
// Query should be set
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.query).toBe('test-filter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load children with the query parameter', async () => {
|
||||||
|
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
|
||||||
|
|
||||||
|
await service.filterNode('container-node', 'my-query');
|
||||||
|
|
||||||
|
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: 'container-node', query: 'my-query' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set expanded to true when filtering', async () => {
|
||||||
|
// Node starts collapsed
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(false);
|
||||||
|
|
||||||
|
await service.filterNode('container-node', 'test-filter');
|
||||||
|
|
||||||
|
// Should be expanded after filtering
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when node not found', async () => {
|
||||||
|
await expect(service.filterNode('non-existent-node', 'query')).rejects.toThrow(
|
||||||
|
'Trying to filter node at path or id non-existent-node not found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when trying to filter a non-expandable node', async () => {
|
||||||
|
await expect(service.filterNode('leaf-node-2', 'query')).rejects.toThrow(
|
||||||
|
'Trying to filter node at id leaf-node-2 that is not expandable'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple calls with different queries', async () => {
|
||||||
|
// First filter
|
||||||
|
await service.filterNode('container-node', 'first-query');
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.query).toBe('first-query');
|
||||||
|
|
||||||
|
// Second filter with different query
|
||||||
|
await service.filterNode('container-node', 'second-query');
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.query).toBe('second-query');
|
||||||
|
|
||||||
|
// Third filter with empty query
|
||||||
|
await service.filterNode('container-node', '');
|
||||||
|
expect(service.state.tree?.children?.['container-node']?.query).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start profiler interaction', async () => {
|
||||||
|
const profiler = {
|
||||||
|
startInteraction: jest.fn(),
|
||||||
|
stopInteraction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new service with profiler
|
||||||
|
const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never);
|
||||||
|
|
||||||
|
await serviceWithProfiler.filterNode('', '');
|
||||||
|
|
||||||
|
expect(profiler.startInteraction).toHaveBeenCalledWith('scopeNodeFilter');
|
||||||
|
expect(profiler.stopInteraction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop profiler even when error is thrown', async () => {
|
||||||
|
const profiler = {
|
||||||
|
startInteraction: jest.fn(),
|
||||||
|
stopInteraction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never);
|
||||||
|
|
||||||
|
// Load initial nodes
|
||||||
|
await serviceWithProfiler.filterNode('', '');
|
||||||
|
|
||||||
|
// Try to filter a non-existent node
|
||||||
|
await expect(serviceWithProfiler.filterNode('non-existent', 'query')).rejects.toThrow();
|
||||||
|
|
||||||
|
// Profiler should still be stopped
|
||||||
|
expect(profiler.stopInteraction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interaction between toggleExpandedNode and filterNode', () => {
|
||||||
|
const expandableNode: ScopeNode = {
|
||||||
|
metadata: { name: 'interaction-node' },
|
||||||
|
spec: {
|
||||||
|
linkId: '',
|
||||||
|
linkType: undefined,
|
||||||
|
parentName: '',
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Interaction Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const childNode: ScopeNode = {
|
||||||
|
metadata: { name: 'interaction-child' },
|
||||||
|
spec: {
|
||||||
|
linkId: 'child-scope',
|
||||||
|
linkType: 'scope',
|
||||||
|
parentName: 'interaction-node',
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Child Node',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
apiClient.fetchNodes = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
|
||||||
|
if (options.parent === '') {
|
||||||
|
return [expandableNode];
|
||||||
|
} else if (options.parent === 'interaction-node') {
|
||||||
|
return [childNode];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.filterNode('', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear query when toggleExpandedNode is called after filterNode', async () => {
|
||||||
|
// Filter with a query
|
||||||
|
await service.filterNode('interaction-node', 'test-query');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('test-query');
|
||||||
|
|
||||||
|
// Toggle should clear the query
|
||||||
|
await service.toggleExpandedNode('interaction-node');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set query when filterNode is called after toggleExpandedNode', async () => {
|
||||||
|
// First toggle (expand)
|
||||||
|
await service.toggleExpandedNode('interaction-node');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('');
|
||||||
|
|
||||||
|
// Filter should set the query
|
||||||
|
await service.filterNode('interaction-node', 'new-query');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('new-query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain expanded state when filtering an already expanded node', async () => {
|
||||||
|
// Expand the node
|
||||||
|
await service.toggleExpandedNode('interaction-node');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true);
|
||||||
|
|
||||||
|
// Filter should keep it expanded
|
||||||
|
await service.filterNode('interaction-node', 'query');
|
||||||
|
expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('redirect on scope selection', () => {
|
describe('redirect on scope selection', () => {
|
||||||
it('should redirect to the first scopeNavigation with /d/ URL when current URL is not a scopeNavigation', async () => {
|
it('should redirect to the first scopeNavigation with /d/ URL when current URL is not a scopeNavigation', async () => {
|
||||||
const mockNavigations: ScopeNavigation[] = [
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: {
|
spec: {
|
||||||
scope: 'test-scope',
|
scope: 'test-scope',
|
||||||
@@ -632,8 +818,6 @@ describe('ScopesSelectorService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
@@ -642,7 +826,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT redirect when the first scopeNavigation does not contain /d/ (e.g., logs drilldown)', async () => {
|
it('should NOT redirect when the first scopeNavigation does not contain /d/ (e.g., logs drilldown)', async () => {
|
||||||
const mockNavigations: ScopeNavigation[] = [
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: {
|
spec: {
|
||||||
scope: 'test-scope',
|
scope: 'test-scope',
|
||||||
@@ -657,8 +841,6 @@ describe('ScopesSelectorService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
@@ -667,7 +849,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT redirect when current URL matches a scopeNavigation', async () => {
|
it('should NOT redirect when current URL matches a scopeNavigation', async () => {
|
||||||
const mockNavigations: ScopeNavigation[] = [
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: {
|
spec: {
|
||||||
scope: 'test-scope',
|
scope: 'test-scope',
|
||||||
@@ -682,8 +864,6 @@ describe('ScopesSelectorService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
@@ -701,7 +881,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT redirect when scopeNavigation does not have a url property', async () => {
|
it('should NOT redirect when scopeNavigation does not have a url property', async () => {
|
||||||
const mockNavigations = [
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: {
|
spec: {
|
||||||
scope: 'test-scope',
|
scope: 'test-scope',
|
||||||
@@ -716,8 +896,6 @@ describe('ScopesSelectorService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as unknown as ScopeNavigation[];
|
] as unknown as ScopeNavigation[];
|
||||||
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
@@ -726,7 +904,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple scopeNavigations and redirect to the first dashboard one', async () => {
|
it('should handle multiple scopeNavigations and redirect to the first dashboard one', async () => {
|
||||||
const mockNavigations: ScopeNavigation[] = [
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: {
|
spec: {
|
||||||
scope: 'test-scope',
|
scope: 'test-scope',
|
||||||
@@ -754,8 +932,6 @@ describe('ScopesSelectorService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
@@ -790,7 +966,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// First update the node to populate the service state
|
// First update the node to populate the service state
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
|
|
||||||
// Then select the scope to set scopeNodeId in selectedScopes
|
// Then select the scope to set scopeNodeId in selectedScopes
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
@@ -837,7 +1013,7 @@ describe('ScopesSelectorService', () => {
|
|||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
// First update the node to populate the service state
|
// First update the node to populate the service state
|
||||||
await service.updateNode('', true, '');
|
await service.filterNode('', '');
|
||||||
|
|
||||||
// Then select the scope to set scopeNodeId in selectedScopes
|
// Then select the scope to set scopeNodeId in selectedScopes
|
||||||
await service.selectScope('test-scope-node');
|
await service.selectScope('test-scope-node');
|
||||||
@@ -851,16 +1027,14 @@ describe('ScopesSelectorService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to scope navigation when scope node is undefined', async () => {
|
it('should fall back to scope navigation when scope node is undefined', async () => {
|
||||||
const mockNavigations: ScopeNavigation[] = [
|
// Don't add the node to the service state, so it will be undefined
|
||||||
|
dashboardsService.state.scopeNavigations = [
|
||||||
{
|
{
|
||||||
spec: { scope: 'test-scope', url: '/d/dashboard1' },
|
spec: { scope: 'test-scope', url: '/d/dashboard1' },
|
||||||
status: { title: 'Dashboard 1', groups: [] },
|
status: { title: 'Dashboard 1', groups: [] },
|
||||||
metadata: { name: 'dashboard1' },
|
metadata: { name: 'dashboard1' },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Don't add the node to the service state, so it will be undefined
|
|
||||||
dashboardsService.state.scopeNavigations = mockNavigations;
|
|
||||||
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
|
||||||
|
|
||||||
await service.changeScopes(['test-scope']);
|
await service.changeScopes(['test-scope']);
|
||||||
|
|||||||
@@ -129,81 +129,38 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
|
|
||||||
// Resets query and toggles expanded state of a node
|
// Resets query and toggles expanded state of a node
|
||||||
public toggleExpandedNode = async (scopeNodeId: string) => {
|
public toggleExpandedNode = async (scopeNodeId: string) => {
|
||||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
this.interactionProfiler?.startInteraction('scopeToggleExpandedNode');
|
||||||
const nodeToToggle = treeNodeAtPath(this.state.tree!, path);
|
|
||||||
|
|
||||||
if (!nodeToToggle) {
|
|
||||||
throw new Error(`Node ${scopeNodeId} not found in tree`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) {
|
|
||||||
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
|
||||||
treeNode.expanded = !nodeToToggle.expanded;
|
|
||||||
treeNode.query = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateState({ tree: newTree });
|
|
||||||
// If we are collapsing, we need to make sure that all the parent's children are avilable
|
|
||||||
if (nodeToToggle.expanded === true) {
|
|
||||||
const parentPath = path.slice(0, -1);
|
|
||||||
const parentNode = treeNodeAtPath(this.state.tree!, parentPath);
|
|
||||||
if (parentNode) {
|
|
||||||
await this.loadNodeChildren(parentPath, parentNode, parentNode.query);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this.loadNodeChildren(path, nodeToToggle);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public filterNode = async (scopeNodeId: string, query: string) => {
|
|
||||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
|
||||||
const nodeToFilter = treeNodeAtPath(this.state.tree!, path);
|
|
||||||
|
|
||||||
if (!nodeToFilter) {
|
|
||||||
throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) {
|
|
||||||
throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
|
||||||
treeNode.expanded = true;
|
|
||||||
treeNode.query = query;
|
|
||||||
});
|
|
||||||
this.updateState({ tree: newTree });
|
|
||||||
|
|
||||||
await this.loadNodeChildren(path, nodeToFilter, query);
|
|
||||||
};
|
|
||||||
|
|
||||||
private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
|
|
||||||
this.interactionProfiler?.startInteraction('scopeNodeDiscovery');
|
|
||||||
|
|
||||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
|
||||||
|
|
||||||
const nodeToExpand = treeNodeAtPath(this.state.tree!, path);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!nodeToExpand) {
|
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
||||||
|
const nodeToToggle = treeNodeAtPath(this.state.tree!, path);
|
||||||
|
|
||||||
|
if (!nodeToToggle) {
|
||||||
throw new Error(`Node ${scopeNodeId} not found in tree`);
|
throw new Error(`Node ${scopeNodeId} not found in tree`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeToExpand.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToExpand.scopeNodeId])) {
|
if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) {
|
||||||
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
|
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nodeToExpand.expanded || nodeToExpand.query !== query) {
|
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
||||||
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
treeNode.expanded = !nodeToToggle.expanded;
|
||||||
treeNode.expanded = true;
|
treeNode.query = '';
|
||||||
treeNode.query = query || '';
|
});
|
||||||
});
|
|
||||||
this.updateState({ tree: newTree });
|
|
||||||
|
|
||||||
await this.loadNodeChildren(path, nodeToExpand, query);
|
this.updateState({ tree: newTree });
|
||||||
|
// If we are collapsing, we need to make sure that all the parent's children are available
|
||||||
|
if (nodeToToggle.expanded) {
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const parentNode = treeNodeAtPath(this.state.tree!, parentPath);
|
||||||
|
if (parentNode) {
|
||||||
|
await this.loadNodeChildren(parentPath, parentNode, parentNode.query);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.loadNodeChildren(path, nodeToToggle);
|
||||||
}
|
}
|
||||||
|
// Catch and throw error so we can ensure the profiler is stopped
|
||||||
|
// todo: leverage component-level
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -211,20 +168,34 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private collapseNode = async (scopeNodeId: string) => {
|
public filterNode = async (scopeNodeId: string, query: string) => {
|
||||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
this.interactionProfiler?.startInteraction('scopeNodeFilter');
|
||||||
|
|
||||||
const nodeToCollapse = treeNodeAtPath(this.state.tree!, path);
|
try {
|
||||||
|
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
||||||
|
const nodeToFilter = treeNodeAtPath(this.state.tree!, path);
|
||||||
|
|
||||||
if (!nodeToCollapse) {
|
if (!nodeToFilter) {
|
||||||
throw new Error(`Trying to collapse node at path or id ${scopeNodeId} not found`);
|
throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) {
|
||||||
|
throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
||||||
|
treeNode.expanded = true;
|
||||||
|
treeNode.query = query;
|
||||||
|
});
|
||||||
|
this.updateState({ tree: newTree });
|
||||||
|
|
||||||
|
await this.loadNodeChildren(path, nodeToFilter, query);
|
||||||
|
// Catch and throw error so we can ensure the profiler is stopped
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.interactionProfiler?.stopInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
|
|
||||||
treeNode.expanded = false;
|
|
||||||
treeNode.query = '';
|
|
||||||
});
|
|
||||||
this.updateState({ tree: newTree });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => {
|
private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => {
|
||||||
@@ -332,15 +303,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
this.updateState({ selectedScopes: newSelectedScopes });
|
this.updateState({ selectedScopes: newSelectedScopes });
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Replace all usage of this function with expandNode and filterNode.
|
|
||||||
// @deprecated
|
|
||||||
public updateNode = async (scopeNodeId: string, expanded: boolean, query: string) => {
|
|
||||||
if (expanded) {
|
|
||||||
return this.expandOrFilterNode(scopeNodeId, query);
|
|
||||||
}
|
|
||||||
return this.collapseNode(scopeNodeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
changeScopes = (scopeNames: string[], parentNodeId?: string, scopeNodeId?: string, redirectOnApply?: boolean) => {
|
changeScopes = (scopeNames: string[], parentNodeId?: string, scopeNodeId?: string, redirectOnApply?: boolean) => {
|
||||||
return this.applyScopes(
|
return this.applyScopes(
|
||||||
scopeNames.map((id, index) => ({
|
scopeNames.map((id, index) => ({
|
||||||
@@ -494,7 +456,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
*/
|
*/
|
||||||
public open = async () => {
|
public open = async () => {
|
||||||
if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) {
|
if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) {
|
||||||
await this.expandOrFilterNode('');
|
await this.filterNode('', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// First close all nodes
|
// First close all nodes
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { BarChartPanel } from './BarChartPanel';
|
|||||||
import { TickSpacingEditor } from './TickSpacingEditor';
|
import { TickSpacingEditor } from './TickSpacingEditor';
|
||||||
import { changeToBarChartPanelMigrationHandler } from './migrations';
|
import { changeToBarChartPanelMigrationHandler } from './migrations';
|
||||||
import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen';
|
import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen';
|
||||||
import { BarChartSuggestionsSupplier } from './suggestions';
|
import { barchartSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
|
||||||
.setPanelChangeHandler(changeToBarChartPanelMigrationHandler)
|
.setPanelChangeHandler(changeToBarChartPanelMigrationHandler)
|
||||||
@@ -257,7 +257,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
|
|||||||
commonOptionsBuilder.addLegendOptions(builder);
|
commonOptionsBuilder.addLegendOptions(builder);
|
||||||
commonOptionsBuilder.addTextSizeOptions(builder, { withValue: true });
|
commonOptionsBuilder.addTextSizeOptions(builder, { withValue: true });
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new BarChartSuggestionsSupplier());
|
.setSuggestionsSupplier(barchartSuggestionsSupplier);
|
||||||
|
|
||||||
function countNumberFields(data?: DataFrame[]): number {
|
function countNumberFields(data?: DataFrame[]): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|||||||
@@ -1,99 +1,112 @@
|
|||||||
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data';
|
import { defaultsDeep } from 'lodash';
|
||||||
|
|
||||||
|
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn, VizOrientation } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
|
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { FieldConfig, Options } from './panelcfg.gen';
|
import { FieldConfig, Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export class BarChartSuggestionsSupplier {
|
const withDefaults = (suggestion: VisualizationSuggestion<Options, FieldConfig>) =>
|
||||||
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
|
defaultsDeep(suggestion, {
|
||||||
return builder.getListAppender<Options, FieldConfig>({
|
options: {
|
||||||
name: SuggestionName.BarChart,
|
showValue: VisibilityMode.Never,
|
||||||
pluginId: 'barchart',
|
legend: {
|
||||||
options: {
|
calcs: [],
|
||||||
showValue: VisibilityMode.Never,
|
displayMode: LegendDisplayMode.List,
|
||||||
legend: {
|
showLegend: true,
|
||||||
calcs: [],
|
placement: 'right',
|
||||||
displayMode: LegendDisplayMode.List,
|
|
||||||
showLegend: true,
|
|
||||||
placement: 'right',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fieldConfig: {
|
},
|
||||||
defaults: {
|
fieldConfig: {
|
||||||
unit: 'short',
|
defaults: {
|
||||||
custom: {},
|
unit: 'short',
|
||||||
},
|
custom: {},
|
||||||
overrides: [],
|
|
||||||
},
|
},
|
||||||
cardOptions: {
|
overrides: [],
|
||||||
previewModifier: (s) => {
|
},
|
||||||
s.options!.barWidth = 0.8;
|
cardOptions: {
|
||||||
},
|
previewModifier: (s) => {
|
||||||
|
s.options!.barWidth = 0.8;
|
||||||
|
s.fieldConfig!.defaults!.custom!.hideFrom = { tooltip: false, legend: true, viz: false }; // hide legend in preview
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
} satisfies VisualizationSuggestion<Options, FieldConfig>);
|
||||||
|
|
||||||
|
export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => {
|
||||||
|
if (dataSummary.frameCount !== 1) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
if (!dataSummary.hasFieldType(FieldType.number) || !dataSummary.hasFieldType(FieldType.string)) {
|
||||||
const list = this.getListWithDefaults(builder);
|
return;
|
||||||
const { dataSummary } = builder;
|
}
|
||||||
|
|
||||||
if (dataSummary.frameCount !== 1) {
|
// if you have this many rows barchart might not be a good fit
|
||||||
return;
|
if (dataSummary.rowCountTotal > 50) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!dataSummary.hasNumberField || !dataSummary.hasStringField) {
|
const result: Array<VisualizationSuggestion<Options, FieldConfig>> = [
|
||||||
return;
|
{
|
||||||
}
|
name: t('barchart.suggestions.vertical', 'Bar chart'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// if you have this many rows barchart might not be a good fit
|
if (dataSummary.fieldCountByType(FieldType.number) > 1) {
|
||||||
if (dataSummary.rowCountTotal > 50) {
|
result.push(
|
||||||
return;
|
{
|
||||||
}
|
name: t('barchart.suggestions.vert-stacked', 'Bar chart - stacked'),
|
||||||
|
|
||||||
// Vertical bars
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarChart,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dataSummary.numberFieldCount > 1) {
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarChartStacked,
|
|
||||||
options: {
|
options: {
|
||||||
stacking: StackingMode.Normal,
|
stacking: StackingMode.Normal,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
list.append({
|
{
|
||||||
name: SuggestionName.BarChartStackedPercent,
|
name: t('barchart.suggestions.vert-stacked-percent', 'Bar chart - stacked by percentage'),
|
||||||
options: {
|
options: {
|
||||||
stacking: StackingMode.Percent,
|
stacking: StackingMode.Percent,
|
||||||
},
|
},
|
||||||
});
|
fieldConfig: {
|
||||||
}
|
overrides: [],
|
||||||
|
defaults: {
|
||||||
// horizontal bars
|
unit: 'percentunit',
|
||||||
list.append({
|
},
|
||||||
name: SuggestionName.BarChartHorizontal,
|
|
||||||
options: {
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dataSummary.numberFieldCount > 1) {
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarChartHorizontalStacked,
|
|
||||||
options: {
|
|
||||||
stacking: StackingMode.Normal,
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
list.append({
|
// horizontal bars
|
||||||
name: SuggestionName.BarChartHorizontalStackedPercent,
|
result.push({
|
||||||
|
name: t('barchart.suggestions.horizontal', 'Horizontal bar chart'),
|
||||||
|
options: {
|
||||||
|
orientation: VizOrientation.Horizontal,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dataSummary.fieldCountByType(FieldType.number) > 1) {
|
||||||
|
result.push(
|
||||||
|
{
|
||||||
|
name: t('barchart.suggestions.hz-stacked', 'Horizontal bar chart - stacked'),
|
||||||
|
options: {
|
||||||
|
orientation: VizOrientation.Horizontal,
|
||||||
|
stacking: StackingMode.Normal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('barchart.suggestions.hz-stacked-percent', 'Horizontal bar chart - stacked by percentage'),
|
||||||
options: {
|
options: {
|
||||||
orientation: VizOrientation.Horizontal,
|
orientation: VizOrientation.Horizontal,
|
||||||
stacking: StackingMode.Percent,
|
stacking: StackingMode.Percent,
|
||||||
},
|
},
|
||||||
});
|
fieldConfig: {
|
||||||
}
|
overrides: [],
|
||||||
|
defaults: {
|
||||||
|
unit: 'percentunit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return result.map(withDefaults);
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm
|
|||||||
import { barGaugePanelMigrationHandler } from './BarGaugeMigrations';
|
import { barGaugePanelMigrationHandler } from './BarGaugeMigrations';
|
||||||
import { BarGaugePanel } from './BarGaugePanel';
|
import { BarGaugePanel } from './BarGaugePanel';
|
||||||
import { Options, defaultOptions } from './panelcfg.gen';
|
import { Options, defaultOptions } from './panelcfg.gen';
|
||||||
import { BarGaugeSuggestionsSupplier } from './suggestions';
|
import { barGaugeSugggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(BarGaugePanel)
|
export const plugin = new PanelPlugin<Options>(BarGaugePanel)
|
||||||
.useFieldConfig()
|
.useFieldConfig()
|
||||||
@@ -151,4 +151,4 @@ export const plugin = new PanelPlugin<Options>(BarGaugePanel)
|
|||||||
})
|
})
|
||||||
.setPanelChangeHandler(sharedSingleStatPanelChangedHandler)
|
.setPanelChangeHandler(sharedSingleStatPanelChangedHandler)
|
||||||
.setMigrationHandler(barGaugePanelMigrationHandler)
|
.setMigrationHandler(barGaugePanelMigrationHandler)
|
||||||
.setSuggestionsSupplier(new BarGaugeSuggestionsSupplier());
|
.setSuggestionsSupplier(barGaugeSugggestionsSupplier);
|
||||||
|
|||||||
@@ -1,115 +1,60 @@
|
|||||||
import { FieldColorModeId, VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data';
|
import { defaultsDeep } from 'lodash';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FieldColorModeId,
|
||||||
|
FieldType,
|
||||||
|
VisualizationSuggestion,
|
||||||
|
VisualizationSuggestionsSupplierFn,
|
||||||
|
VizOrientation,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
import { BarGaugeDisplayMode } from '@grafana/ui';
|
import { BarGaugeDisplayMode } from '@grafana/ui';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export class BarGaugeSuggestionsSupplier {
|
const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
defaultsDeep(suggestion, {
|
||||||
const { dataSummary } = builder;
|
options: {
|
||||||
|
displayMode: BarGaugeDisplayMode.Basic,
|
||||||
if (!dataSummary.hasData || !dataSummary.hasNumberField) {
|
orientation: VizOrientation.Horizontal,
|
||||||
return;
|
},
|
||||||
}
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
const list = builder.getListAppender<Options, {}>({
|
color: {
|
||||||
name: '',
|
mode: FieldColorModeId.ContinuousGrYlRd,
|
||||||
pluginId: 'bargauge',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
},
|
||||||
overrides: [],
|
|
||||||
},
|
},
|
||||||
});
|
overrides: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// This is probably not a good option for many numeric fields
|
const BAR_LIMIT = 30;
|
||||||
if (dataSummary.numberFieldCount > 50) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// To use show individual row values we also need a string field to give each value a name
|
export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 30) {
|
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
||||||
list.append({
|
return;
|
||||||
name: SuggestionName.BarGaugeBasic,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
displayMode: BarGaugeDisplayMode.Basic,
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarGaugeLCD,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
displayMode: BarGaugeDisplayMode.Lcd,
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarGaugeBasic,
|
|
||||||
options: {
|
|
||||||
displayMode: BarGaugeDisplayMode.Basic,
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
reduceOptions: {
|
|
||||||
values: false,
|
|
||||||
calcs: ['lastNotNull'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarGaugeLCD,
|
|
||||||
options: {
|
|
||||||
displayMode: BarGaugeDisplayMode.Lcd,
|
|
||||||
orientation: VizOrientation.Horizontal,
|
|
||||||
reduceOptions: {
|
|
||||||
values: false,
|
|
||||||
calcs: ['lastNotNull'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// This is probably not a good option for many numeric fields
|
||||||
|
if (dataSummary.fieldCountByType(FieldType.number) > BAR_LIMIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<VisualizationSuggestion<Options>> = [
|
||||||
|
{ name: t('bargauge.suggestions.basic', 'Bar gauge') },
|
||||||
|
{
|
||||||
|
name: t('bargauge.suggestions.lcd', 'Bar gauge - LCD'),
|
||||||
|
options: {
|
||||||
|
displayMode: BarGaugeDisplayMode.Lcd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const shouldUseRawValues =
|
||||||
|
dataSummary.hasFieldType(FieldType.string) &&
|
||||||
|
dataSummary.frameCount === 1 &&
|
||||||
|
dataSummary.rowCountTotal <= BAR_LIMIT;
|
||||||
|
|
||||||
|
return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues));
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
|
|||||||
|
|
||||||
import { CandlestickPanel } from './CandlestickPanel';
|
import { CandlestickPanel } from './CandlestickPanel';
|
||||||
import { CandlestickData, getCandlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields';
|
import { CandlestickData, getCandlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields';
|
||||||
import { CandlestickSuggestionsSupplier } from './suggestions';
|
import { candlestickSuggestionSupplier } from './suggestions';
|
||||||
import { defaultCandlestickColors, defaultOptions, Options, VizDisplayMode, ColorStrategy, CandleStyle } from './types';
|
import { defaultCandlestickColors, defaultOptions, Options, VizDisplayMode, ColorStrategy, CandleStyle } from './types';
|
||||||
|
|
||||||
const numericFieldFilter = (f: Field) => f.type === FieldType.number;
|
const numericFieldFilter = (f: Field) => f.type === FieldType.number;
|
||||||
@@ -147,4 +147,4 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(CandlestickPane
|
|||||||
commonOptionsBuilder.addLegendOptions(builder);
|
commonOptionsBuilder.addLegendOptions(builder);
|
||||||
})
|
})
|
||||||
.setDataSupport({ annotations: true, alertStates: true })
|
.setDataSupport({ annotations: true, alertStates: true })
|
||||||
.setSuggestionsSupplier(new CandlestickSuggestionsSupplier());
|
.setSuggestionsSupplier(candlestickSuggestionSupplier);
|
||||||
|
|||||||
@@ -1,54 +1,29 @@
|
|||||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { prepareCandlestickFields } from './fields';
|
import { prepareCandlestickFields } from './fields';
|
||||||
import { defaultOptions, Options } from './types';
|
import { defaultOptions, Options } from './types';
|
||||||
|
|
||||||
export class CandlestickSuggestionsSupplier {
|
export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
if (
|
||||||
const { dataSummary } = builder;
|
!dataSummary.rawFrames ||
|
||||||
|
!dataSummary.hasData ||
|
||||||
if (
|
dataSummary.fieldCountByType(FieldType.time) < 1 ||
|
||||||
!builder.data?.series ||
|
dataSummary.fieldCountByType(FieldType.number) < 2 ||
|
||||||
!dataSummary.hasData ||
|
dataSummary.fieldCountByType(FieldType.number) > 10
|
||||||
dataSummary.timeFieldCount < 1 ||
|
) {
|
||||||
dataSummary.numberFieldCount < 2 ||
|
return;
|
||||||
dataSummary.numberFieldCount > 10
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = prepareCandlestickFields(builder.data.series, defaultOptions, config.theme2);
|
|
||||||
if (!info) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular timeseries
|
|
||||||
if (info.open === info.high && info.open === info.low) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = builder.getListAppender<Options, {}>({
|
|
||||||
name: '',
|
|
||||||
pluginId: 'candlestick',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.Candlestick,
|
|
||||||
options: defaultOptions,
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const info = prepareCandlestickFields(dataSummary.rawFrames, defaultOptions, config.theme2);
|
||||||
|
if (!info) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular timeseries
|
||||||
|
if (info.open === info.high && info.open === info.low) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best }];
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { CoreApp, PanelProps } from '@grafana/data';
|
|||||||
import { FlameGraph, checkFields, getMessageCheckFieldsResult } from '@grafana/flamegraph';
|
import { FlameGraph, checkFields, getMessageCheckFieldsResult } from '@grafana/flamegraph';
|
||||||
import { PanelDataErrorView, reportInteraction, config } from '@grafana/runtime';
|
import { PanelDataErrorView, reportInteraction, config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { Options } from './types';
|
||||||
|
|
||||||
function interaction(name: string, context: Record<string, string | number> = {}) {
|
function interaction(name: string, context: Record<string, string | number> = {}) {
|
||||||
reportInteraction(`grafana_flamegraph_${name}`, {
|
reportInteraction(`grafana_flamegraph_${name}`, {
|
||||||
app: CoreApp.Unknown,
|
app: CoreApp.Unknown,
|
||||||
@@ -10,7 +12,7 @@ function interaction(name: string, context: Record<string, string | number> = {}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlameGraphPanel = (props: PanelProps) => {
|
export const FlameGraphPanel = (props: PanelProps<Options>) => {
|
||||||
const wrongFields = checkFields(props.data.series[0]);
|
const wrongFields = checkFields(props.data.series[0]);
|
||||||
if (wrongFields) {
|
if (wrongFields) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
|
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
|
||||||
|
import { checkFields } from '@grafana/flamegraph';
|
||||||
|
|
||||||
import { FlameGraphPanel } from './FlameGraphPanel';
|
import { FlameGraphPanel } from './FlameGraphPanel';
|
||||||
import { FlameGraphSuggestionsSupplier } from './suggestions';
|
import { Options } from './types';
|
||||||
|
|
||||||
const flamegraphConfigOptions = [FieldConfigProperty.Unit, FieldConfigProperty.Decimals];
|
const flamegraphConfigOptions = [FieldConfigProperty.Unit, FieldConfigProperty.Decimals];
|
||||||
|
|
||||||
export const plugin = new PanelPlugin(FlameGraphPanel)
|
export const plugin = new PanelPlugin<Options>(FlameGraphPanel)
|
||||||
.setSuggestionsSupplier(new FlameGraphSuggestionsSupplier())
|
// check that the first frame of the data has the required fields for a flamegraph
|
||||||
|
.setSuggestionsSupplier((ds) => {
|
||||||
|
if (!ds.rawFrames?.some((frame) => checkFields(frame) === undefined)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
s.options = s.options || {};
|
||||||
|
s.options.showFlameGraphOnly = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
.useFieldConfig({
|
.useFieldConfig({
|
||||||
disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => !flamegraphConfigOptions.includes(v)),
|
disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => !flamegraphConfigOptions.includes(v)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
|
||||||
import { checkFields } from '@grafana/flamegraph';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
export class FlameGraphSuggestionsSupplier {
|
|
||||||
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
return builder.getListAppender<{}, {}>({
|
|
||||||
name: SuggestionName.FlameGraph,
|
|
||||||
pluginId: 'flamegraph',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
if (!builder.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataFrame = builder.data.series[0];
|
|
||||||
if (!dataFrame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wrongFields = checkFields(dataFrame);
|
|
||||||
if (wrongFields) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getListWithDefaults(builder).append({
|
|
||||||
name: SuggestionName.FlameGraph,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface Options {
|
||||||
|
showFlameGraphOnly?: boolean;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm
|
|||||||
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
|
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
|
||||||
import { GaugePanel } from './GaugePanel';
|
import { GaugePanel } from './GaugePanel';
|
||||||
import { Options, defaultOptions } from './panelcfg.gen';
|
import { Options, defaultOptions } from './panelcfg.gen';
|
||||||
import { GaugeSuggestionsSupplier } from './suggestions';
|
import { gaugeSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(GaugePanel)
|
export const plugin = new PanelPlugin<Options>(GaugePanel)
|
||||||
.useFieldConfig({
|
.useFieldConfig({
|
||||||
@@ -88,5 +88,5 @@ export const plugin = new PanelPlugin<Options>(GaugePanel)
|
|||||||
commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true });
|
commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true });
|
||||||
})
|
})
|
||||||
.setPanelChangeHandler(gaugePanelChangedHandler)
|
.setPanelChangeHandler(gaugePanelChangedHandler)
|
||||||
.setSuggestionsSupplier(new GaugeSuggestionsSupplier())
|
.setSuggestionsSupplier(gaugeSuggestionsSupplier)
|
||||||
.setMigrationHandler(gaugePanelMigrationHandler);
|
.setMigrationHandler(gaugePanelMigrationHandler);
|
||||||
|
|||||||
@@ -1,89 +1,63 @@
|
|||||||
import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data';
|
import { defaultsDeep } from 'lodash';
|
||||||
import { GraphFieldConfig } from '@grafana/ui';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export class GaugeSuggestionsSupplier {
|
const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
defaultsDeep(suggestion, {
|
||||||
const { dataSummary } = builder;
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
if (!dataSummary.hasData || !dataSummary.hasNumberField) {
|
thresholds: {
|
||||||
return;
|
steps: [
|
||||||
}
|
{ value: -Infinity, color: 'green' },
|
||||||
|
{ value: 70, color: 'orange' },
|
||||||
// for many fields / series this is probably not a good fit
|
{ value: 85, color: 'red' },
|
||||||
if (dataSummary.numberFieldCount >= 50) {
|
],
|
||||||
return;
|
mode: ThresholdsMode.Percentage,
|
||||||
}
|
|
||||||
|
|
||||||
const list = builder.getListAppender<Options, GraphFieldConfig>({
|
|
||||||
name: SuggestionName.Gauge,
|
|
||||||
pluginId: 'gauge',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
thresholds: {
|
|
||||||
steps: [
|
|
||||||
{ value: -Infinity, color: 'green' },
|
|
||||||
{ value: 70, color: 'orange' },
|
|
||||||
{ value: 85, color: 'red' },
|
|
||||||
],
|
|
||||||
mode: ThresholdsMode.Percentage,
|
|
||||||
},
|
|
||||||
custom: {},
|
|
||||||
},
|
},
|
||||||
overrides: [],
|
custom: {},
|
||||||
},
|
},
|
||||||
cardOptions: {
|
overrides: [],
|
||||||
previewModifier: (s) => {
|
},
|
||||||
if (s.options?.reduceOptions?.values) {
|
cardOptions: {
|
||||||
s.options.reduceOptions.limit = 2;
|
previewModifier: (s) => {
|
||||||
}
|
if (s.options?.reduceOptions?.values) {
|
||||||
},
|
s.options.reduceOptions.limit = 2;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
} satisfies VisualizationSuggestion<Options>);
|
||||||
|
|
||||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) {
|
const GAUGE_LIMIT = 10;
|
||||||
list.append({
|
|
||||||
name: SuggestionName.Gauge,
|
export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||||
options: {
|
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
||||||
reduceOptions: {
|
return;
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.GaugeNoThresholds,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
showThresholdMarkers: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.Gauge,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: false,
|
|
||||||
calcs: ['lastNotNull'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.GaugeNoThresholds,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: false,
|
|
||||||
calcs: ['lastNotNull'],
|
|
||||||
},
|
|
||||||
showThresholdMarkers: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// for many fields / series this is probably not a good fit
|
||||||
|
if (dataSummary.fieldCountByType(FieldType.number) > GAUGE_LIMIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<VisualizationSuggestion<Options>> = [
|
||||||
|
{ name: t('gauge.suggestions.arc', 'Gauge') },
|
||||||
|
{
|
||||||
|
name: t('gauge.suggestions.no-thresholds', 'Gauge - no thresholds'),
|
||||||
|
options: {
|
||||||
|
showThresholdMarkers: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// sometimes, we want to de-aggregate the data for the gauge suggestion
|
||||||
|
const shouldUseRawValues =
|
||||||
|
dataSummary.hasFieldType(FieldType.string) &&
|
||||||
|
dataSummary.frameCount === 1 &&
|
||||||
|
dataSummary.rowCountTotal <= GAUGE_LIMIT;
|
||||||
|
|
||||||
|
return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues));
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LayersEditor } from './editor/LayersEditor';
|
|||||||
import { MapViewEditor } from './editor/MapViewEditor';
|
import { MapViewEditor } from './editor/MapViewEditor';
|
||||||
import { getLayerEditor } from './editor/layerEditor';
|
import { getLayerEditor } from './editor/layerEditor';
|
||||||
import { mapPanelChangedHandler, mapMigrationHandler } from './migrations';
|
import { mapPanelChangedHandler, mapMigrationHandler } from './migrations';
|
||||||
|
import { geomapSuggestionsSupplier } from './suggestions';
|
||||||
import { defaultMapViewConfig, Options, TooltipMode, GeomapInstanceState } from './types';
|
import { defaultMapViewConfig, Options, TooltipMode, GeomapInstanceState } from './types';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(GeomapPanel)
|
export const plugin = new PanelPlugin<Options>(GeomapPanel)
|
||||||
@@ -171,4 +172,5 @@ export const plugin = new PanelPlugin<Options>(GeomapPanel)
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
|
.setSuggestionsSupplier(geomapSuggestionsSupplier);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
|
import { GraphFieldConfig } from '@grafana/ui';
|
||||||
|
import { getGeometryField, getDefaultLocationMatchers } from 'app/features/geo/utils/location';
|
||||||
|
|
||||||
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
|
export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||||
|
dataSummary
|
||||||
|
) => {
|
||||||
|
if (!dataSummary.hasData || !dataSummary.rawFrames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// use getGeometryField to see if any frames have geolocation info
|
||||||
|
const location = getDefaultLocationMatchers();
|
||||||
|
if (!dataSummary.rawFrames.some((frame) => !getGeometryField(frame, location).warning)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
score: VisualizationSuggestionScore.Best,
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
s.options!.controls = {
|
||||||
|
showZoom: false,
|
||||||
|
showScale: false,
|
||||||
|
showAttribution: false,
|
||||||
|
showMeasure: false,
|
||||||
|
};
|
||||||
|
// FIXME: this doesn't work. I want to disable legends in the preview.
|
||||||
|
s.options?.layers?.forEach((layer) => {
|
||||||
|
layer.config = layer.config || {};
|
||||||
|
layer.config.showLegend = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
jest.mock('ol-mapbox-style', () => ({}));
|
||||||
|
jest.mock('geotiff', () => ({}));
|
||||||
|
|
||||||
|
import BaseLayer from 'ol/layer/Base';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataFrame,
|
||||||
|
DataQueryRequest,
|
||||||
|
FieldType,
|
||||||
|
LoadingState,
|
||||||
|
MapLayerHandler,
|
||||||
|
MapLayerOptions,
|
||||||
|
PanelData,
|
||||||
|
TimeRange,
|
||||||
|
} from '@grafana/data';
|
||||||
|
|
||||||
|
import { applyLayerFilter } from './layers';
|
||||||
|
|
||||||
|
describe('applyLayerFilter', () => {
|
||||||
|
const createDataFrame = (refId: string): DataFrame => ({
|
||||||
|
refId,
|
||||||
|
fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3], config: {} }],
|
||||||
|
length: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply filter when query exists and is visible', () => {
|
||||||
|
const update = jest.fn();
|
||||||
|
const handler: MapLayerHandler = {
|
||||||
|
init: () => ({}) as BaseLayer,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
const options: MapLayerOptions = {
|
||||||
|
name: 'Test',
|
||||||
|
type: 'markers',
|
||||||
|
filterData: { id: 'byRefId', options: 'A' },
|
||||||
|
};
|
||||||
|
const panelData: PanelData = {
|
||||||
|
series: [createDataFrame('A'), createDataFrame('B')],
|
||||||
|
state: LoadingState.Done,
|
||||||
|
timeRange: {} as TimeRange,
|
||||||
|
request: { targets: [{ refId: 'A' }, { refId: 'B' }] } as DataQueryRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyLayerFilter(handler, options, panelData);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
series: [createDataFrame('A')],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty series when query exists but is hidden', () => {
|
||||||
|
const update = jest.fn();
|
||||||
|
const handler: MapLayerHandler = {
|
||||||
|
init: () => ({}) as BaseLayer,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
const options: MapLayerOptions = {
|
||||||
|
name: 'Test',
|
||||||
|
type: 'markers',
|
||||||
|
filterData: { id: 'byRefId', options: 'A' },
|
||||||
|
};
|
||||||
|
const panelData: PanelData = {
|
||||||
|
series: [createDataFrame('B')],
|
||||||
|
state: LoadingState.Done,
|
||||||
|
timeRange: {} as TimeRange,
|
||||||
|
request: { targets: [{ refId: 'A', hide: true }, { refId: 'B' }] } as DataQueryRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyLayerFilter(handler, options, panelData);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
series: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply filter when query does not exist', () => {
|
||||||
|
const update = jest.fn();
|
||||||
|
const handler: MapLayerHandler = {
|
||||||
|
init: () => ({}) as BaseLayer,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
const options: MapLayerOptions = {
|
||||||
|
name: 'Test',
|
||||||
|
type: 'markers',
|
||||||
|
filterData: { id: 'byRefId', options: 'C' },
|
||||||
|
};
|
||||||
|
const panelData: PanelData = {
|
||||||
|
series: [createDataFrame('A'), createDataFrame('B')],
|
||||||
|
state: LoadingState.Done,
|
||||||
|
timeRange: {} as TimeRange,
|
||||||
|
request: { targets: [{ refId: 'A' }, { refId: 'B' }] } as DataQueryRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyLayerFilter(handler, options, panelData);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith(panelData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through all data when no filter is configured', () => {
|
||||||
|
const update = jest.fn();
|
||||||
|
const handler: MapLayerHandler = {
|
||||||
|
init: () => ({}) as BaseLayer,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
const options: MapLayerOptions = {
|
||||||
|
name: 'Test',
|
||||||
|
type: 'markers',
|
||||||
|
};
|
||||||
|
const panelData: PanelData = {
|
||||||
|
series: [createDataFrame('A'), createDataFrame('B')],
|
||||||
|
state: LoadingState.Done,
|
||||||
|
timeRange: {} as TimeRange,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyLayerFilter(handler, options, panelData);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith(panelData);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,13 @@ export const applyLayerFilter = (
|
|||||||
let panelData = panelDataProps;
|
let panelData = panelDataProps;
|
||||||
if (options.filterData) {
|
if (options.filterData) {
|
||||||
const matcherFunc = getFrameMatchers(options.filterData);
|
const matcherFunc = getFrameMatchers(options.filterData);
|
||||||
if (panelData.series.some(matcherFunc)) {
|
|
||||||
|
const queryExists = panelData.request?.targets.some((target) => {
|
||||||
|
return target.refId === options.filterData?.options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only apply filter if the target query exists
|
||||||
|
if (queryExists) {
|
||||||
panelData = {
|
panelData = {
|
||||||
...panelData,
|
...panelData,
|
||||||
series: panelData.series.filter(matcherFunc),
|
series: panelData.series.filter(matcherFunc),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { HeatmapPanel } from './HeatmapPanel';
|
|||||||
import { prepareHeatmapData } from './fields';
|
import { prepareHeatmapData } from './fields';
|
||||||
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
|
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
|
||||||
import { colorSchemes, quantizeScheme } from './palettes';
|
import { colorSchemes, quantizeScheme } from './palettes';
|
||||||
import { HeatmapSuggestionsSupplier } from './suggestions';
|
import { heatmapSuggestionsSupplier } from './suggestions';
|
||||||
import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './types';
|
import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './types';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
|
export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
|
||||||
@@ -472,5 +472,5 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
|
|||||||
annotations?.some((df) => df.meta?.custom?.resultType === 'exemplar'),
|
annotations?.some((df) => df.meta?.custom?.resultType === 'exemplar'),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new HeatmapSuggestionsSupplier())
|
.setSuggestionsSupplier(heatmapSuggestionsSupplier)
|
||||||
.setDataSupport({ annotations: true });
|
.setDataSupport({ annotations: true });
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
createDataFrame,
|
||||||
|
DataFrameType,
|
||||||
|
FieldType,
|
||||||
|
getPanelDataSummary,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
} from '@grafana/data';
|
||||||
|
|
||||||
|
import { heatmapSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
|
describe('heatmap suggestions', () => {
|
||||||
|
describe('applicability', () => {
|
||||||
|
it('should not suggest for data without time field', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
|
||||||
|
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest for data with time and number fields', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] },
|
||||||
|
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
|
||||||
|
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scoring', () => {
|
||||||
|
it('should score this as "OK" if the data is not particularly heatmap-y', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] },
|
||||||
|
{ name: 'value1', type: FieldType.number, values: [1, 2, 3] },
|
||||||
|
{ name: 'value2', type: FieldType.number, values: [4, 5, 6] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.OK })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells])(
|
||||||
|
'should score this as "Best" if the data explicitly has %s frame type',
|
||||||
|
(frameType: DataFrameType) => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
meta: { type: frameType },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: FieldType.time,
|
||||||
|
values: [1609459200000, 1609462800000, 1609466400000],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value1',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value2',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [4, 5, 6],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value3',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [7, 8, 9],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should score this as "Best" if the data has "ge" labels', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: FieldType.time,
|
||||||
|
values: [1609459200000, 1609462800000, 1609466400000],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value1',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: { ge: '-Inf' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value2',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [4, 5, 6],
|
||||||
|
labels: { ge: '0' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value3',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [7, 8, 9],
|
||||||
|
labels: { ge: '10' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should score this as "Best" if the data has "le" labels', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: FieldType.time,
|
||||||
|
values: [1609459200000, 1609462800000, 1609466400000],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value1',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: { le: '1' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value2',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [4, 5, 6],
|
||||||
|
labels: { le: '2' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value2',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [6, 2, 6],
|
||||||
|
labels: { le: '4' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value3',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [7, 8, 9],
|
||||||
|
labels: { le: 'Inf' },
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should score this as "Best" if the field names are numeric in a way that makes sense', () => {
|
||||||
|
const dataSummary = getPanelDataSummary([
|
||||||
|
createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: FieldType.time,
|
||||||
|
values: [1609459200000, 1609462800000, 1609466400000],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '0',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '10',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [4, 5, 6],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '20',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [7, 8, 9],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Inf',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [10, 9, 8],
|
||||||
|
display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestions = heatmapSuggestionsSupplier(dataSummary);
|
||||||
|
expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,45 +1,73 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
import {
|
||||||
|
DataFrameType,
|
||||||
|
FieldType,
|
||||||
|
PanelDataSummary,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
VisualizationSuggestionsSupplierFn,
|
||||||
|
} from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { GraphFieldConfig } from '@grafana/schema';
|
||||||
|
|
||||||
import { prepareHeatmapData } from './fields';
|
import { prepareHeatmapData } from './fields';
|
||||||
import { quantizeScheme } from './palettes';
|
import { quantizeScheme } from './palettes';
|
||||||
import { Options, defaultOptions } from './types';
|
import { Options, defaultOptions } from './types';
|
||||||
|
|
||||||
export class HeatmapSuggestionsSupplier {
|
function determineScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore {
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
// look to see if the data has an explicity marker for heatmap data on it.
|
||||||
const { dataSummary } = builder;
|
if ([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells].some((t) => dataSummary.hasDataFrameType(t))) {
|
||||||
|
return VisualizationSuggestionScore.Best;
|
||||||
if (
|
|
||||||
!builder.data?.series ||
|
|
||||||
!dataSummary.hasData ||
|
|
||||||
dataSummary.timeFieldCount < 1 ||
|
|
||||||
dataSummary.numberFieldCount < 2 ||
|
|
||||||
dataSummary.numberFieldCount > 10
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palette = quantizeScheme(defaultOptions.color, config.theme2);
|
|
||||||
const info = prepareHeatmapData({
|
|
||||||
frames: builder.data.series,
|
|
||||||
options: defaultOptions,
|
|
||||||
palette,
|
|
||||||
theme: config.theme2,
|
|
||||||
});
|
|
||||||
if (!info || info.warning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.getListAppender<Options, {}>({
|
|
||||||
name: '',
|
|
||||||
pluginId: 'heatmap',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we'll also look more closely at frames which return between 3 and 10 numeric fields.
|
||||||
|
if (dataSummary.fieldCountByType(FieldType.number) > 2 || dataSummary.fieldCountByType(FieldType.number) <= 10) {
|
||||||
|
// look through the names of the panels
|
||||||
|
const hasPotentialHeatmapSeries = dataSummary.rawFrames!.some((frame) => {
|
||||||
|
for (const field of frame.fields) {
|
||||||
|
if (field.type === FieldType.number) {
|
||||||
|
// if the field name, or "ge" or "le" label on the field, are numeric, then it's very possibly part of a heatmap.
|
||||||
|
if ([field.name, field.labels?.ge, field.labels?.le].some((v) => !isNaN(Number(v)))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// if at least all but 1 of the numeric fields in the frame have numeric names, then this is probably a heatmap.
|
||||||
|
// (the out-of-place one would be "Inf" or "-Inf")
|
||||||
|
if (hasPotentialHeatmapSeries) {
|
||||||
|
return VisualizationSuggestionScore.Best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VisualizationSuggestionScore.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||||
|
dataSummary: PanelDataSummary
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!dataSummary.rawFrames ||
|
||||||
|
!dataSummary.hasData ||
|
||||||
|
!dataSummary.hasFieldType(FieldType.time) ||
|
||||||
|
!dataSummary.hasFieldType(FieldType.number)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse the frame into a heatmap structure to see if it's possible.
|
||||||
|
const palette = quantizeScheme(defaultOptions.color, config.theme2);
|
||||||
|
const info = prepareHeatmapData({
|
||||||
|
frames: dataSummary.rawFrames,
|
||||||
|
options: defaultOptions,
|
||||||
|
palette,
|
||||||
|
theme: config.theme2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we can't parse the data into a heatmap, then bail out and prevent showing suggestions.
|
||||||
|
if (!info || info.warning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ score: determineScore(dataSummary) }];
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
identityOverrideProcessor,
|
identityOverrideProcessor,
|
||||||
PanelPlugin,
|
PanelPlugin,
|
||||||
histogramFieldInfo,
|
histogramFieldInfo,
|
||||||
|
buildHistogram,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
DataFrameType,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui';
|
import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui';
|
||||||
@@ -149,4 +152,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel)
|
|||||||
|
|
||||||
commonOptionsBuilder.addHideFrom(builder);
|
commonOptionsBuilder.addHideFrom(builder);
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
.setSuggestionsSupplier((ds) => {
|
||||||
|
if (ds.rawFrames && ds.hasData && buildHistogram(ds.rawFrames)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
score: ds.hasDataFrameType(DataFrameType.Histogram)
|
||||||
|
? VisualizationSuggestionScore.Best
|
||||||
|
: VisualizationSuggestionScore.OK,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data';
|
import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription, FieldType } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { LogsPanel } from './LogsPanel';
|
import { LogsPanel } from './LogsPanel';
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
import { LogsPanelSuggestionsSupplier } from './suggestions';
|
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(LogsPanel)
|
export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||||
.setPanelOptions((builder, context) => {
|
.setPanelOptions((builder, context) => {
|
||||||
@@ -207,4 +207,6 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
|||||||
defaultValue: LogsSortOrder.Descending,
|
defaultValue: LogsSortOrder.Descending,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new LogsPanelSuggestionsSupplier());
|
.setSuggestionsSupplier(
|
||||||
|
showDefaultSuggestion((ds) => ds.hasData && ds.hasFieldType(FieldType.time) && ds.hasFieldType(FieldType.string))
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
|
||||||
|
|
||||||
export class LogsPanelSuggestionsSupplier {
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
const list = builder.getListAppender<Options, {}>({
|
|
||||||
name: '',
|
|
||||||
pluginId: 'logs',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { dataSummary: ds } = builder;
|
|
||||||
|
|
||||||
// Require a string & time field
|
|
||||||
if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ds.preferredVisualisationType === 'logs') {
|
|
||||||
list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best });
|
|
||||||
} else {
|
|
||||||
list.append({ name: SuggestionName.Logs });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { t } from '@grafana/i18n';
|
|||||||
import { NodeGraphPanel } from './NodeGraphPanel';
|
import { NodeGraphPanel } from './NodeGraphPanel';
|
||||||
import { ArcOptionsEditor } from './editor/ArcOptionsEditor';
|
import { ArcOptionsEditor } from './editor/ArcOptionsEditor';
|
||||||
import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen';
|
import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen';
|
||||||
import { NodeGraphSuggestionsSupplier } from './suggestions';
|
import { nodeGraphSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
|
export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
|
||||||
.useFieldConfig({
|
.useFieldConfig({
|
||||||
@@ -92,4 +92,4 @@ export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new NodeGraphSuggestionsSupplier());
|
.setSuggestionsSupplier(nodeGraphSuggestionsSupplier);
|
||||||
|
|||||||
@@ -1,71 +1,60 @@
|
|||||||
import { DataFrame, FieldType, VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
export class NodeGraphSuggestionsSupplier {
|
import { Options } from './panelcfg.gen';
|
||||||
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
return builder.getListAppender<{}, {}>({
|
|
||||||
name: SuggestionName.NodeGraph,
|
|
||||||
pluginId: 'nodeGraph',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCorrectFields(frames: DataFrame[]): boolean {
|
function checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean {
|
||||||
let hasNodesFrame = false;
|
let hasCorrectFields = true;
|
||||||
let hasEdgesFrame = false;
|
|
||||||
|
|
||||||
const nodeFields: Array<[string, FieldType]> = [
|
for (const field of fields) {
|
||||||
['id', FieldType.string],
|
const [name, type] = field;
|
||||||
['title', FieldType.string],
|
const frameField = frame.fields.find((f) => f.name === name);
|
||||||
['mainstat', FieldType.number],
|
if (!frameField || type !== frameField.type) {
|
||||||
];
|
hasCorrectFields = false;
|
||||||
const edgeFields: Array<[string, FieldType]> = [
|
break;
|
||||||
['id', FieldType.string],
|
|
||||||
['source', FieldType.string],
|
|
||||||
['target', FieldType.string],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const frame of frames) {
|
|
||||||
if (this.checkFields(nodeFields, frame)) {
|
|
||||||
hasNodesFrame = true;
|
|
||||||
}
|
|
||||||
if (this.checkFields(edgeFields, frame)) {
|
|
||||||
hasEdgesFrame = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasNodesFrame && hasEdgesFrame;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean {
|
|
||||||
let hasCorrectFields = true;
|
|
||||||
|
|
||||||
for (const field of fields) {
|
|
||||||
const [name, type] = field;
|
|
||||||
const frameField = frame.fields.find((f) => f.name === name);
|
|
||||||
if (!frameField || type !== frameField.type) {
|
|
||||||
hasCorrectFields = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasCorrectFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
if (!builder.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCorrectFields = this.hasCorrectFields(builder.data.series);
|
|
||||||
const nodeGraphFrames = builder.data.series.filter(
|
|
||||||
(df) => df.meta && df.meta.preferredVisualisationType === 'nodeGraph'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasCorrectFields || nodeGraphFrames.length === 2) {
|
|
||||||
this.getListWithDefaults(builder).append({
|
|
||||||
name: SuggestionName.NodeGraph,
|
|
||||||
score: VisualizationSuggestionScore.Best,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return hasCorrectFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function frameHasCorrectFields(frames: DataFrame[]): boolean {
|
||||||
|
let hasNodesFrame = false;
|
||||||
|
let hasEdgesFrame = false;
|
||||||
|
|
||||||
|
const nodeFields: Array<[string, FieldType]> = [
|
||||||
|
['id', FieldType.string],
|
||||||
|
['title', FieldType.string],
|
||||||
|
['mainstat', FieldType.number],
|
||||||
|
];
|
||||||
|
const edgeFields: Array<[string, FieldType]> = [
|
||||||
|
['id', FieldType.string],
|
||||||
|
['source', FieldType.string],
|
||||||
|
['target', FieldType.string],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (checkFields(nodeFields, frame)) {
|
||||||
|
hasNodesFrame = true;
|
||||||
|
}
|
||||||
|
if (checkFields(edgeFields, frame)) {
|
||||||
|
hasEdgesFrame = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasNodesFrame && hasEdgesFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||||
|
if (!dataSummary.rawFrames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCorrectFields = frameHasCorrectFields(dataSummary.rawFrames);
|
||||||
|
const nodeGraphFrames = dataSummary.hasPreferredVisualisationType('nodeGraph');
|
||||||
|
|
||||||
|
if (!hasCorrectFields && !nodeGraphFrames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ score: VisualizationSuggestionScore.Best }];
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { addStandardDataReduceOptions } from '../stat/common';
|
|||||||
import { PieChartPanel } from './PieChartPanel';
|
import { PieChartPanel } from './PieChartPanel';
|
||||||
import { PieChartPanelChangedHandler } from './migrations';
|
import { PieChartPanelChangedHandler } from './migrations';
|
||||||
import { Options, FieldConfig, PieChartType, PieChartLabels, PieChartLegendValues } from './panelcfg.gen';
|
import { Options, FieldConfig, PieChartType, PieChartLabels, PieChartLegendValues } from './panelcfg.gen';
|
||||||
import { PieChartSuggestionsSupplier } from './suggestions';
|
import { piechartSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel)
|
||||||
.setPanelChangeHandler(PieChartPanelChangedHandler)
|
.setPanelChangeHandler(PieChartPanelChangedHandler)
|
||||||
@@ -92,4 +92,4 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(PieChartPanel)
|
|||||||
showIf: (c) => c.legend.showLegend !== false,
|
showIf: (c) => c.legend.showLegend !== false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new PieChartSuggestionsSupplier());
|
.setSuggestionsSupplier(piechartSuggestionsSupplier);
|
||||||
|
|||||||
@@ -1,79 +1,78 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
import { defaultsDeep } from 'lodash';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
VisualizationSuggestion,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
VisualizationSuggestionsSupplierFn,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
import { LegendDisplayMode } from '@grafana/schema';
|
import { LegendDisplayMode } from '@grafana/schema';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { PieChartLabels, Options, PieChartType } from './panelcfg.gen';
|
import { PieChartLabels, Options, PieChartType } from './panelcfg.gen';
|
||||||
|
|
||||||
export class PieChartSuggestionsSupplier {
|
const withDefaults = (suggestion: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
defaultsDeep(suggestion, {
|
||||||
const list = builder.getListAppender<Options, {}>({
|
options: {
|
||||||
name: SuggestionName.PieChart,
|
displayLabels: [PieChartLabels.Percent],
|
||||||
pluginId: 'piechart',
|
legend: {
|
||||||
options: {
|
calcs: [],
|
||||||
reduceOptions: {
|
displayMode: LegendDisplayMode.Hidden,
|
||||||
values: false,
|
placement: 'right',
|
||||||
calcs: ['lastNotNull'],
|
values: [],
|
||||||
},
|
showLegend: false,
|
||||||
displayLabels: [PieChartLabels.Percent],
|
|
||||||
legend: {
|
|
||||||
calcs: [],
|
|
||||||
displayMode: LegendDisplayMode.Hidden,
|
|
||||||
placement: 'right',
|
|
||||||
values: [],
|
|
||||||
showLegend: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
} satisfies VisualizationSuggestion<Options>);
|
||||||
|
|
||||||
const { dataSummary } = builder;
|
const SLICE_MAX = 30;
|
||||||
|
const SLICE_MIN = 2;
|
||||||
|
|
||||||
if (!dataSummary.hasNumberField) {
|
export const piechartSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (dataSummary) => {
|
||||||
return;
|
if (!dataSummary.hasFieldType(FieldType.number)) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1) {
|
const suggestions: Array<VisualizationSuggestion<Options>> = [
|
||||||
// if many values this or single value PieChart is not a good option
|
{
|
||||||
if (dataSummary.rowCountTotal > 30 || dataSummary.rowCountTotal < 2) {
|
name: t('piechart.suggestions.pie', 'Pie chart'),
|
||||||
return;
|
},
|
||||||
}
|
{
|
||||||
|
name: t('piechart.suggestions.donut', 'Donut chart'),
|
||||||
list.append({
|
|
||||||
name: SuggestionName.PieChart,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.PieChartDonut,
|
|
||||||
options: {
|
|
||||||
reduceOptions: {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
},
|
|
||||||
pieType: PieChartType.Donut,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSummary.numberFieldCount > 30 || dataSummary.numberFieldCount < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.PieChart,
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.PieChartDonut,
|
|
||||||
options: {
|
options: {
|
||||||
pieType: PieChartType.Donut,
|
pieType: PieChartType.Donut,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let shouldUseRawValues = false;
|
||||||
|
|
||||||
|
// we're filtering out data which has more than 30 slices or less than 2, and we're also
|
||||||
|
// determining whether the reduce options should be set based on the data summary.
|
||||||
|
if (dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1) {
|
||||||
|
if (dataSummary.rowCountTotal > SLICE_MAX && dataSummary.rowCountTotal < SLICE_MIN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUseRawValues = true;
|
||||||
|
} else if (
|
||||||
|
dataSummary.fieldCountByType(FieldType.number) > SLICE_MAX ||
|
||||||
|
dataSummary.fieldCountByType(FieldType.number) < SLICE_MIN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return suggestions.map((s) => {
|
||||||
|
const result = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues);
|
||||||
|
// bump the score up to best if we have exactly one numeric and one string field
|
||||||
|
if (
|
||||||
|
dataSummary.fieldCount === 2 &&
|
||||||
|
dataSummary.fieldCountByType(FieldType.string) === 1 &&
|
||||||
|
dataSummary.fieldCountByType(FieldType.number) === 1
|
||||||
|
) {
|
||||||
|
result.score = VisualizationSuggestionScore.Best;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { EffectsEditor } from './EffectsEditor';
|
|||||||
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
|
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
|
||||||
import { RadialBarPanel } from './RadialBarPanel';
|
import { RadialBarPanel } from './RadialBarPanel';
|
||||||
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
|
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
|
||||||
import { radialBarSuggestionsHandler } from './suggestions';
|
import { radialBarSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(RadialBarPanel)
|
export const plugin = new PanelPlugin<Options>(RadialBarPanel)
|
||||||
.useFieldConfig({})
|
.useFieldConfig({})
|
||||||
@@ -112,6 +112,6 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
|
|||||||
defaultValue: defaultGaugePanelEffects,
|
defaultValue: defaultGaugePanelEffects,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(radialBarSuggestionsHandler)
|
.setSuggestionsSupplier(radialBarSuggestionsSupplier)
|
||||||
.setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge)
|
.setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge)
|
||||||
.setPanelChangeHandler(gaugePanelChangedHandler);
|
.setPanelChangeHandler(gaugePanelChangedHandler);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data';
|
import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data';
|
||||||
|
|
||||||
import { radialBarSuggestionsHandler } from './suggestions';
|
import { radialBarSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
describe('RadialBarPanel Suggestions', () => {
|
describe('RadialBarPanel Suggestions', () => {
|
||||||
it('does not suggest gauge if no data is present', () => {
|
it('does not suggest gauge if no data is present', () => {
|
||||||
expect(radialBarSuggestionsHandler(getPanelDataSummary([]))).toBeFalsy();
|
expect(radialBarSuggestionsSupplier(getPanelDataSummary([]))).toBeFalsy();
|
||||||
expect(radialBarSuggestionsHandler(getPanelDataSummary(undefined))).toBeFalsy();
|
expect(radialBarSuggestionsSupplier(getPanelDataSummary(undefined))).toBeFalsy();
|
||||||
expect(
|
expect(
|
||||||
radialBarSuggestionsHandler(
|
radialBarSuggestionsSupplier(
|
||||||
getPanelDataSummary([
|
getPanelDataSummary([
|
||||||
createDataFrame({
|
createDataFrame({
|
||||||
fields: [
|
fields: [
|
||||||
@@ -27,7 +27,7 @@ describe('RadialBarPanel Suggestions', () => {
|
|||||||
{ name: 'status', type: FieldType.string },
|
{ name: 'status', type: FieldType.string },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(radialBarSuggestionsHandler(getPanelDataSummary([df]))).toBeFalsy();
|
expect(radialBarSuggestionsSupplier(getPanelDataSummary([df]))).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not suggest gauge if there are too many numeric fields', () => {
|
it('does not suggest gauge if there are too many numeric fields', () => {
|
||||||
@@ -35,12 +35,12 @@ describe('RadialBarPanel Suggestions', () => {
|
|||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} });
|
fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} });
|
||||||
}
|
}
|
||||||
expect(radialBarSuggestionsHandler(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy();
|
expect(radialBarSuggestionsSupplier(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suggests gauge for a single numeric field', () => {
|
it('suggests gauge for a single numeric field', () => {
|
||||||
expect(
|
expect(
|
||||||
radialBarSuggestionsHandler(
|
radialBarSuggestionsSupplier(
|
||||||
getPanelDataSummary([
|
getPanelDataSummary([
|
||||||
createDataFrame({
|
createDataFrame({
|
||||||
fields: [
|
fields: [
|
||||||
@@ -58,7 +58,7 @@ describe('RadialBarPanel Suggestions', () => {
|
|||||||
|
|
||||||
it('suggests gauge for a few numeric fields, with other fields mixed in', () => {
|
it('suggests gauge for a few numeric fields, with other fields mixed in', () => {
|
||||||
expect(
|
expect(
|
||||||
radialBarSuggestionsHandler(
|
radialBarSuggestionsSupplier(
|
||||||
getPanelDataSummary([
|
getPanelDataSummary([
|
||||||
createDataFrame({
|
createDataFrame({
|
||||||
fields: [
|
fields: [
|
||||||
@@ -136,7 +136,7 @@ describe('RadialBarPanel Suggestions', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => {
|
])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => {
|
||||||
const suggestions = radialBarSuggestionsHandler(getPanelDataSummary(dataframes));
|
const suggestions = radialBarSuggestionsSupplier(getPanelDataSummary(dataframes));
|
||||||
const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] };
|
const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] };
|
||||||
if (Array.isArray(suggestions)) {
|
if (Array.isArray(suggestions)) {
|
||||||
for (const suggestion of suggestions) {
|
for (const suggestion of suggestions) {
|
||||||
|
|||||||
@@ -8,10 +8,39 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { GraphFieldConfig } from '@grafana/ui';
|
import { GraphFieldConfig } from '@grafana/ui';
|
||||||
|
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
const withDefaults = (
|
||||||
|
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
|
||||||
|
): VisualizationSuggestion<Options, GraphFieldConfig> =>
|
||||||
|
defaultsDeep(suggestion, {
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
if (s.options?.reduceOptions) {
|
||||||
|
s.options.reduceOptions.limit = 4;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// styles: [{
|
||||||
|
// name: t('gauge.suggestions.style.circular', 'Glowing'),
|
||||||
|
// options: {
|
||||||
|
// effects: {
|
||||||
|
// rounded: true,
|
||||||
|
// barGlow: true,
|
||||||
|
// centerGlow: true,
|
||||||
|
// spotlight: true,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// }, {
|
||||||
|
// name: t('gauge.suggestions.style.simple', 'Simple'),
|
||||||
|
// }]
|
||||||
|
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
|
||||||
|
|
||||||
|
const MAX_GAUGES = 10;
|
||||||
|
|
||||||
|
export const radialBarSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||||
dataSummary
|
dataSummary
|
||||||
) => {
|
) => {
|
||||||
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) {
|
||||||
@@ -19,69 +48,40 @@ export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn<Opt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for many fields / series this is probably not a good fit
|
// for many fields / series this is probably not a good fit
|
||||||
if (dataSummary.fieldCountByType(FieldType.number) >= 10) {
|
if (dataSummary.fieldCountByType(FieldType.number) > MAX_GAUGES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withDefaults = (
|
const suggestions: Array<VisualizationSuggestion<Options, GraphFieldConfig>> = [
|
||||||
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
|
{ name: t('gauge.suggestions.arc', 'Gauge') },
|
||||||
): VisualizationSuggestion<Options, GraphFieldConfig> => {
|
{
|
||||||
// if there is a string field and there are few enough rows, we assume it's tabular data and not numeric series data,
|
|
||||||
// and the de-aggregated version of the viz probably makes more sense
|
|
||||||
const isTabularData =
|
|
||||||
dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10;
|
|
||||||
return defaultsDeep(suggestion, {
|
|
||||||
options: {
|
|
||||||
reduceOptions: isTabularData
|
|
||||||
? {
|
|
||||||
values: true,
|
|
||||||
calcs: [],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
values: false,
|
|
||||||
calcs: ['lastNotNull'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: isTabularData
|
|
||||||
? {
|
|
||||||
color: { mode: FieldColorModeId.PaletteClassic },
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
cardOptions: {
|
|
||||||
previewModifier: (s) => {
|
|
||||||
if (s.options?.reduceOptions) {
|
|
||||||
s.options.reduceOptions.limit = 4;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// styles: [{
|
|
||||||
// name: t('gauge.suggestions.style.circular', 'Glowing'),
|
|
||||||
// options: {
|
|
||||||
// effects: {
|
|
||||||
// rounded: true,
|
|
||||||
// barGlow: true,
|
|
||||||
// centerGlow: true,
|
|
||||||
// spotlight: true,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// }, {
|
|
||||||
// name: t('gauge.suggestions.style.simple', 'Simple'),
|
|
||||||
// }]
|
|
||||||
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
withDefaults({ name: t('gauge.suggestions.arc', 'Gauge') }),
|
|
||||||
withDefaults({
|
|
||||||
name: t('gauge.suggestions.circular', 'Circular gauge'),
|
name: t('gauge.suggestions.circular', 'Circular gauge'),
|
||||||
options: {
|
options: {
|
||||||
shape: 'circle',
|
shape: 'circle',
|
||||||
showThresholdMarkers: false,
|
showThresholdMarkers: false,
|
||||||
barWidthFactor: 0.3,
|
barWidthFactor: 0.3,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const shouldUseRawValues =
|
||||||
|
dataSummary.hasFieldType(FieldType.string) &&
|
||||||
|
dataSummary.frameCount === 1 &&
|
||||||
|
dataSummary.rowCountTotal <= MAX_GAUGES;
|
||||||
|
|
||||||
|
return suggestions.map((s) => {
|
||||||
|
const suggestion = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues);
|
||||||
|
|
||||||
|
if (shouldUseRawValues) {
|
||||||
|
suggestion.fieldConfig = suggestion.fieldConfig ?? {
|
||||||
|
defaults: {},
|
||||||
|
overrides: [],
|
||||||
|
};
|
||||||
|
suggestion.fieldConfig.defaults.color = suggestion.fieldConfig.defaults.color ?? {
|
||||||
|
mode: FieldColorModeId.PaletteClassic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { statPanelChangedHandler } from './StatMigrations';
|
|||||||
import { StatPanel } from './StatPanel';
|
import { StatPanel } from './StatPanel';
|
||||||
import { addStandardDataReduceOptions, addOrientationOption } from './common';
|
import { addStandardDataReduceOptions, addOrientationOption } from './common';
|
||||||
import { defaultOptions, Options } from './panelcfg.gen';
|
import { defaultOptions, Options } from './panelcfg.gen';
|
||||||
import { StatSuggestionsSupplier } from './suggestions';
|
import { statSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options>(StatPanel)
|
export const plugin = new PanelPlugin<Options>(StatPanel)
|
||||||
.useFieldConfig()
|
.useFieldConfig()
|
||||||
@@ -137,5 +137,5 @@ export const plugin = new PanelPlugin<Options>(StatPanel)
|
|||||||
})
|
})
|
||||||
.setNoPadding()
|
.setNoPadding()
|
||||||
.setPanelChangeHandler(statPanelChangedHandler)
|
.setPanelChangeHandler(statPanelChangedHandler)
|
||||||
.setSuggestionsSupplier(new StatSuggestionsSupplier())
|
.setSuggestionsSupplier(statSuggestionsSupplier)
|
||||||
.setMigrationHandler(sharedSingleStatMigrationHandler);
|
.setMigrationHandler(sharedSingleStatMigrationHandler);
|
||||||
|
|||||||
@@ -1,41 +1,46 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
import { defaultsDeep } from 'lodash';
|
||||||
import { BigValueColorMode, BigValueGraphMode, GraphFieldConfig } from '@grafana/schema';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema';
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export class StatSuggestionsSupplier {
|
const withDefaults = (s: VisualizationSuggestion<Options>): VisualizationSuggestion<Options> =>
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
defaultsDeep(s, {
|
||||||
const { dataSummary: ds } = builder;
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
if (!ds.hasData) {
|
unit: 'short',
|
||||||
return;
|
custom: {},
|
||||||
}
|
|
||||||
|
|
||||||
const list = builder.getListAppender<Options, GraphFieldConfig>({
|
|
||||||
name: SuggestionName.Stat,
|
|
||||||
pluginId: 'stat',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
unit: 'short',
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
},
|
||||||
cardOptions: {
|
overrides: [],
|
||||||
previewModifier: (s) => {
|
},
|
||||||
if (s.options?.reduceOptions?.values) {
|
cardOptions: {
|
||||||
s.options.reduceOptions.limit = 1;
|
previewModifier: (s) => {
|
||||||
}
|
if (s.options?.reduceOptions?.values) {
|
||||||
},
|
s.options.reduceOptions.limit = 1;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
} satisfies VisualizationSuggestion<Options>);
|
||||||
|
|
||||||
// String and number field with low row count show individual rows
|
export const statSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options> = (ds) => {
|
||||||
if (ds.hasStringField && ds.hasNumberField && ds.frameCount === 1 && ds.rowCountTotal < 10) {
|
if (!ds.hasData) {
|
||||||
list.append({
|
return;
|
||||||
name: SuggestionName.Stat,
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<VisualizationSuggestion<Options>> = [];
|
||||||
|
|
||||||
|
// String and number field with low row count show individual rows
|
||||||
|
if (
|
||||||
|
ds.hasFieldType(FieldType.string) &&
|
||||||
|
ds.hasFieldType(FieldType.number) &&
|
||||||
|
ds.frameCount === 1 &&
|
||||||
|
ds.rowCountTotal < 10
|
||||||
|
) {
|
||||||
|
suggestions.push(
|
||||||
|
{
|
||||||
|
name: t('stat.suggestions.stat-discrete-values', 'Stat - discrete values'),
|
||||||
options: {
|
options: {
|
||||||
reduceOptions: {
|
reduceOptions: {
|
||||||
values: true,
|
values: true,
|
||||||
@@ -43,9 +48,9 @@ export class StatSuggestionsSupplier {
|
|||||||
fields: '/.*/',
|
fields: '/.*/',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
list.append({
|
{
|
||||||
name: SuggestionName.StatColoredBackground,
|
name: t('stat.suggestions.stat-discrete-values-color-background', 'Stat - discrete values - color background'),
|
||||||
options: {
|
options: {
|
||||||
reduceOptions: {
|
reduceOptions: {
|
||||||
values: true,
|
values: true,
|
||||||
@@ -54,36 +59,38 @@ export class StatSuggestionsSupplier {
|
|||||||
},
|
},
|
||||||
colorMode: BigValueColorMode.Background,
|
colorMode: BigValueColorMode.Background,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Just a single string field
|
// just a single string field
|
||||||
if (ds.stringFieldCount === 1 && ds.frameCount === 1 && ds.rowCountTotal < 10 && ds.fieldCount === 1) {
|
if (ds.fieldCount === 1 && ds.hasFieldType(FieldType.string)) {
|
||||||
list.append({
|
suggestions.push({
|
||||||
name: SuggestionName.Stat,
|
name: t('stat.suggestions.stat-single-string', 'Stat - single string'),
|
||||||
options: {
|
options: {
|
||||||
reduceOptions: {
|
reduceOptions: {
|
||||||
values: true,
|
values: true,
|
||||||
calcs: [],
|
calcs: [],
|
||||||
fields: '/.*/',
|
fields: '/.*/',
|
||||||
},
|
|
||||||
colorMode: BigValueColorMode.None,
|
|
||||||
},
|
},
|
||||||
});
|
colorMode: BigValueColorMode.None,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (ds.hasNumberField && ds.hasTimeField) {
|
// aggregated suggestions for number fields
|
||||||
list.append({
|
if (ds.hasFieldType(FieldType.number) && ds.hasFieldType(FieldType.time)) {
|
||||||
|
suggestions.push(
|
||||||
|
{
|
||||||
options: {
|
options: {
|
||||||
reduceOptions: {
|
reduceOptions: {
|
||||||
values: false,
|
values: false,
|
||||||
calcs: ['lastNotNull'],
|
calcs: ['lastNotNull'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
{
|
||||||
list.append({
|
name: t('stat.suggestions.stat-color-background', 'Stat - color background'),
|
||||||
name: SuggestionName.StatColoredBackground,
|
|
||||||
options: {
|
options: {
|
||||||
reduceOptions: {
|
reduceOptions: {
|
||||||
values: false,
|
values: false,
|
||||||
@@ -92,7 +99,9 @@ export class StatSuggestionsSupplier {
|
|||||||
graphMode: BigValueGraphMode.None,
|
graphMode: BigValueGraphMode.None,
|
||||||
colorMode: BigValueColorMode.Background,
|
colorMode: BigValueColorMode.Background,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return suggestions.map(withDefaults);
|
||||||
|
};
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
|
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
|
||||||
if (ds.preferredVisualisationType === 'logs') {
|
if (ds.hasPreferredVisualisationType('logs')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
|
import { FieldColorModeId, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { AxisPlacement, VisibilityMode } from '@grafana/schema';
|
import { AxisPlacement, VisibilityMode } from '@grafana/schema';
|
||||||
import { commonOptionsBuilder } from '@grafana/ui';
|
import { commonOptionsBuilder } from '@grafana/ui';
|
||||||
|
|
||||||
import { StatusHistoryPanel } from './StatusHistoryPanel';
|
import { StatusHistoryPanel } from './StatusHistoryPanel';
|
||||||
import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen';
|
import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen';
|
||||||
import { StatusHistorySuggestionsSupplier } from './suggestions';
|
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
|
||||||
.useFieldConfig({
|
.useFieldConfig({
|
||||||
@@ -113,5 +112,42 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
|
|||||||
commonOptionsBuilder.addLegendOptions(builder, false);
|
commonOptionsBuilder.addLegendOptions(builder, false);
|
||||||
commonOptionsBuilder.addTooltipOptions(builder);
|
commonOptionsBuilder.addTooltipOptions(builder);
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new StatusHistorySuggestionsSupplier())
|
.setSuggestionsSupplier((ds) => {
|
||||||
|
if (!ds.hasData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This panel needs a time field and a string or number field
|
||||||
|
if (
|
||||||
|
!ds.hasFieldType(FieldType.time) ||
|
||||||
|
(!ds.hasFieldType(FieldType.string) && !ds.hasFieldType(FieldType.number))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are many series then they won't fit on y-axis so this panel is not good fit
|
||||||
|
if (ds.fieldCountByType(FieldType.number) >= 30) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there a lot of data points for each series then this is not a good match
|
||||||
|
if (ds.rowCountMax > 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
|
||||||
|
if (ds.hasPreferredVisualisationType('logs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
s.options!.colWidth = 0.7;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
.setDataSupport({ annotations: true });
|
.setDataSupport({ annotations: true });
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { FieldColorModeId, VisualizationSuggestionsBuilder } from '@grafana/data';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { Options, FieldConfig } from './panelcfg.gen';
|
|
||||||
|
|
||||||
export class StatusHistorySuggestionsSupplier {
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
const { dataSummary: ds } = builder;
|
|
||||||
|
|
||||||
if (!ds.hasData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This panel needs a time field and a string or number field
|
|
||||||
if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are many series then they won't fit on y-axis so this panel is not good fit
|
|
||||||
if (ds.numberFieldCount >= 30) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there a lot of data points for each series then this is not a good match
|
|
||||||
if (ds.rowCountMax > 100) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probably better ways to filter out this by inspecting the types of string values so view this as temporary
|
|
||||||
if (ds.preferredVisualisationType === 'logs') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = builder.getListAppender<Options, FieldConfig>({
|
|
||||||
name: '',
|
|
||||||
pluginId: 'status-history',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
cardOptions: {
|
|
||||||
previewModifier: (s) => {
|
|
||||||
s.options!.colWidth = 0.7;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({ name: SuggestionName.StatusHistory });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import { TableCellOptionEditor } from './TableCellOptionEditor';
|
|||||||
import { TablePanel } from './TablePanel';
|
import { TablePanel } from './TablePanel';
|
||||||
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
|
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
|
||||||
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
|
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
|
||||||
import { TableSuggestionsSupplier } from './suggestions';
|
import { tableSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
||||||
.setPanelChangeHandler(tablePanelChangedHandler)
|
.setPanelChangeHandler(tablePanelChangedHandler)
|
||||||
@@ -227,4 +227,4 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
|||||||
defaultValue: defaultOptions?.enablePagination,
|
defaultValue: defaultOptions?.enablePagination,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new TableSuggestionsSupplier());
|
.setSuggestionsSupplier(tableSuggestionsSupplier);
|
||||||
|
|||||||
@@ -1,38 +1,33 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data';
|
||||||
import { TableFieldOptions } from '@grafana/schema';
|
|
||||||
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
|
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options, FieldConfig } from './panelcfg.gen';
|
||||||
|
|
||||||
export class TableSuggestionsSupplier {
|
function getTableSuggestionScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore {
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
if (dataSummary.hasPreferredVisualisationType('table')) {
|
||||||
const list = builder.getListAppender<Options, TableFieldOptions>({
|
return VisualizationSuggestionScore.Best;
|
||||||
name: SuggestionName.Table,
|
|
||||||
pluginId: 'table',
|
|
||||||
options: {},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
cardOptions: {
|
|
||||||
previewModifier: (s) => {
|
|
||||||
s.fieldConfig!.defaults.custom!.minWidth = 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there are not data suggest table anyway but use icon instead of real preview
|
|
||||||
if (builder.dataSummary.fieldCount === 0) {
|
|
||||||
list.append({
|
|
||||||
cardOptions: {
|
|
||||||
imgSrc: icnTablePanelSvg,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
list.append({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// table is best suited to showing many fields with many rows.
|
||||||
|
if (dataSummary.fieldCountMax > 5 && dataSummary.rowCountMax > 50) {
|
||||||
|
return VisualizationSuggestionScore.Good;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VisualizationSuggestionScore.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const tableSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, FieldConfig> = (dataSummary) => [
|
||||||
|
{
|
||||||
|
score: getTableSuggestionScore(dataSummary),
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
if (s.fieldConfig && s.fieldConfig.defaults.custom) {
|
||||||
|
s.fieldConfig.defaults.custom.minWidth = 50;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// If there is no data, suggest table anyway, but use icon instead of real preview
|
||||||
|
// TODO: delete this in once "new" suggestions are fully rolled out
|
||||||
|
imgSrc: dataSummary.fieldCount === 0 ? icnTablePanelSvg : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TimezonesEditor } from './TimezonesEditor';
|
|||||||
import { defaultGraphConfig, getGraphFieldConfig } from './config';
|
import { defaultGraphConfig, getGraphFieldConfig } from './config';
|
||||||
import { graphPanelChangedHandler } from './migrations';
|
import { graphPanelChangedHandler } from './migrations';
|
||||||
import { FieldConfig, Options } from './panelcfg.gen';
|
import { FieldConfig, Options } from './panelcfg.gen';
|
||||||
import { TimeSeriesSuggestionsSupplier } from './suggestions';
|
import { timeseriesSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
|
||||||
.setPanelChangeHandler(graphPanelChangedHandler)
|
.setPanelChangeHandler(graphPanelChangedHandler)
|
||||||
@@ -26,5 +26,5 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
|
|||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new TimeSeriesSuggestionsSupplier())
|
.setSuggestionsSupplier(timeseriesSuggestionsSupplier)
|
||||||
.setDataSupport({ annotations: true, alertStates: true });
|
.setDataSupport({ annotations: true, alertStates: true });
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import { defaultsDeep } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FieldColorModeId,
|
DataFrameType,
|
||||||
VisualizationSuggestionsBuilder,
|
|
||||||
DataTransformerID,
|
DataTransformerID,
|
||||||
|
FieldType,
|
||||||
PanelPluginVisualizationSuggestion,
|
PanelPluginVisualizationSuggestion,
|
||||||
|
VisualizationSuggestion,
|
||||||
|
VisualizationSuggestionScore,
|
||||||
|
VisualizationSuggestionsSupplierFn,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
import {
|
import {
|
||||||
GraphDrawStyle,
|
GraphDrawStyle,
|
||||||
GraphFieldConfig,
|
GraphFieldConfig,
|
||||||
@@ -13,209 +19,142 @@ import {
|
|||||||
StackingMode,
|
StackingMode,
|
||||||
} from '@grafana/schema';
|
} from '@grafana/schema';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
|
||||||
export class TimeSeriesSuggestionsSupplier {
|
const MAX_BARS = 100;
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
const MAX_ROWS_SMOOTH_CHART = 200;
|
||||||
const { dataSummary } = builder;
|
|
||||||
|
|
||||||
if (!dataSummary.hasTimeField || !dataSummary.hasNumberField || dataSummary.rowCountTotal < 2) {
|
const withDefaults = (
|
||||||
return;
|
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
|
||||||
}
|
): VisualizationSuggestion<Options, GraphFieldConfig> =>
|
||||||
|
defaultsDeep(suggestion, {
|
||||||
const list = builder.getListAppender<Options, GraphFieldConfig>({
|
options: {
|
||||||
name: SuggestionName.LineChart,
|
legend: {
|
||||||
pluginId: 'timeseries',
|
calcs: [],
|
||||||
options: {
|
displayMode: LegendDisplayMode.Hidden,
|
||||||
legend: {
|
placement: 'right',
|
||||||
calcs: [],
|
showLegend: false,
|
||||||
displayMode: LegendDisplayMode.Hidden,
|
|
||||||
placement: 'right',
|
|
||||||
showLegend: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fieldConfig: {
|
},
|
||||||
defaults: {
|
fieldConfig: {
|
||||||
custom: {},
|
defaults: {
|
||||||
},
|
custom: {},
|
||||||
overrides: [],
|
|
||||||
},
|
},
|
||||||
cardOptions: {
|
overrides: [],
|
||||||
previewModifier: (s) => {
|
},
|
||||||
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
cardOptions: {
|
||||||
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
previewModifier: (s) => {
|
||||||
}
|
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
||||||
},
|
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
} satisfies VisualizationSuggestion<Options, GraphFieldConfig>);
|
||||||
|
|
||||||
const maxBarsCount = 100;
|
const areaChart = (name: string, stacking?: StackingMode) => ({
|
||||||
|
name,
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
|
custom: {
|
||||||
|
fillOpacity: 25,
|
||||||
|
...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
list.append({
|
const barChart = (name: string, stacking?: StackingMode) => ({
|
||||||
name: SuggestionName.LineChart,
|
name,
|
||||||
});
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
|
custom: {
|
||||||
|
drawStyle: GraphDrawStyle.Bars,
|
||||||
|
fillOpacity: 100,
|
||||||
|
lineWidth: 1,
|
||||||
|
gradientMode: GraphGradientMode.Hue,
|
||||||
|
...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (dataSummary.rowCountMax < 200) {
|
// TODO: all "gradient color scheme" suggestions have been removed. they will be re-added as part of the "styles" feature.
|
||||||
list.append({
|
|
||||||
name: SuggestionName.LineChartSmooth,
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {
|
|
||||||
lineInterpolation: LineInterpolation.Smooth,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single series suggestions
|
export const timeseriesSuggestionsSupplier: VisualizationSuggestionsSupplierFn<Options, GraphFieldConfig> = (
|
||||||
if (dataSummary.numberFieldCount === 1) {
|
dataSummary
|
||||||
list.append({
|
) => {
|
||||||
name: SuggestionName.AreaChart,
|
if (
|
||||||
fieldConfig: {
|
!dataSummary.hasFieldType(FieldType.time) ||
|
||||||
defaults: {
|
!dataSummary.hasFieldType(FieldType.number) ||
|
||||||
custom: {
|
dataSummary.rowCountTotal < 2
|
||||||
fillOpacity: 25,
|
) {
|
||||||
},
|
return;
|
||||||
},
|
}
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
const score: VisualizationSuggestionScore =
|
||||||
name: SuggestionName.LineChartGradientColorScheme,
|
dataSummary.hasDataFrameType(DataFrameType.TimeSeriesLong) ||
|
||||||
fieldConfig: {
|
dataSummary.hasDataFrameType(DataFrameType.TimeSeriesWide) ||
|
||||||
defaults: {
|
dataSummary.hasDataFrameType(DataFrameType.TimeSeriesMulti)
|
||||||
color: {
|
? VisualizationSuggestionScore.Good
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
: VisualizationSuggestionScore.OK;
|
||||||
},
|
|
||||||
custom: {
|
|
||||||
gradientMode: GraphGradientMode.Scheme,
|
|
||||||
lineInterpolation: LineInterpolation.Smooth,
|
|
||||||
lineWidth: 3,
|
|
||||||
fillOpacity: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dataSummary.rowCountMax < maxBarsCount) {
|
const suggestions: Array<VisualizationSuggestion<Options, GraphFieldConfig>> = [
|
||||||
list.append({
|
{
|
||||||
name: SuggestionName.BarChart,
|
name: t('timeseries.suggestions.line', 'Line chart'),
|
||||||
fieldConfig: {
|
},
|
||||||
defaults: {
|
];
|
||||||
custom: {
|
|
||||||
drawStyle: GraphDrawStyle.Bars,
|
|
||||||
fillOpacity: 100,
|
|
||||||
lineWidth: 1,
|
|
||||||
gradientMode: GraphGradientMode.Hue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
if (dataSummary.rowCountMax < MAX_ROWS_SMOOTH_CHART) {
|
||||||
name: SuggestionName.BarChartGradientColorScheme,
|
suggestions.push({
|
||||||
fieldConfig: {
|
name: t('timeseries.suggestions.line-smooth', 'Line chart - smooth'),
|
||||||
defaults: {
|
|
||||||
color: {
|
|
||||||
mode: FieldColorModeId.ContinuousGrYlRd,
|
|
||||||
},
|
|
||||||
custom: {
|
|
||||||
drawStyle: GraphDrawStyle.Bars,
|
|
||||||
fillOpacity: 90,
|
|
||||||
lineWidth: 1,
|
|
||||||
gradientMode: GraphGradientMode.Scheme,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple series suggestions
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.AreaChartStacked,
|
|
||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
defaults: {
|
defaults: {
|
||||||
custom: {
|
custom: {
|
||||||
fillOpacity: 25,
|
lineInterpolation: LineInterpolation.Smooth,
|
||||||
stacking: {
|
|
||||||
mode: StackingMode.Normal,
|
|
||||||
group: 'A',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overrides: [],
|
overrides: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
list.append({
|
// Single-series suggestions
|
||||||
name: SuggestionName.AreaChartStackedPercent,
|
if (dataSummary.fieldCountByType(FieldType.number) === 1) {
|
||||||
fieldConfig: {
|
suggestions.push(areaChart(t('timeseries.suggestions.area', 'Area chart')));
|
||||||
defaults: {
|
|
||||||
custom: {
|
|
||||||
fillOpacity: 25,
|
|
||||||
stacking: {
|
|
||||||
mode: StackingMode.Percent,
|
|
||||||
group: 'A',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dataSummary.rowCountTotal / dataSummary.numberFieldCount < maxBarsCount) {
|
if (dataSummary.rowCountMax < MAX_BARS) {
|
||||||
list.append({
|
suggestions.push(barChart(t('timeseries.suggestions.bar', 'Bar chart')));
|
||||||
name: SuggestionName.BarChartStacked,
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {
|
|
||||||
drawStyle: GraphDrawStyle.Bars,
|
|
||||||
fillOpacity: 100,
|
|
||||||
lineWidth: 1,
|
|
||||||
gradientMode: GraphGradientMode.Hue,
|
|
||||||
stacking: {
|
|
||||||
mode: StackingMode.Normal,
|
|
||||||
group: 'A',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
list.append({
|
|
||||||
name: SuggestionName.BarChartStackedPercent,
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {
|
|
||||||
drawStyle: GraphDrawStyle.Bars,
|
|
||||||
fillOpacity: 100,
|
|
||||||
lineWidth: 1,
|
|
||||||
gradientMode: GraphGradientMode.Hue,
|
|
||||||
stacking: {
|
|
||||||
mode: StackingMode.Percent,
|
|
||||||
group: 'A',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Multiple series suggestions
|
||||||
|
else {
|
||||||
|
suggestions.push(
|
||||||
|
areaChart(t('timeseries.suggestions.area-stacked', 'Area chart - stacked'), StackingMode.Normal),
|
||||||
|
areaChart(
|
||||||
|
t('timeseries.suggestions.area-stacked-percentage', 'Area chart - stacked by percentage'),
|
||||||
|
StackingMode.Percent
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dataSummary.rowCountTotal / dataSummary.fieldCountByType(FieldType.number) < MAX_BARS) {
|
||||||
|
suggestions.push(
|
||||||
|
barChart(t('timeseries.suggestions.bar-stacked', 'Bar chart - stacked'), StackingMode.Normal),
|
||||||
|
barChart(
|
||||||
|
t('timeseries.suggestions.bar-stacked-percent', 'Bar chart - stacked by percentage'),
|
||||||
|
StackingMode.Percent
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.map((s) => {
|
||||||
|
s.score = score;
|
||||||
|
return withDefaults(s);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// This will try to get a suggestion that will add a long to wide conversion
|
// This will try to get a suggestion that will add a long to wide conversion
|
||||||
export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined {
|
export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
|
import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils';
|
||||||
|
|
||||||
import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch';
|
import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch';
|
||||||
|
|
||||||
import { FiltersEditor } from './FiltersEditor';
|
import { FiltersEditor } from './FiltersEditor';
|
||||||
import { TracesPanel } from './TracesPanel';
|
import { TracesPanel } from './TracesPanel';
|
||||||
import { TracesSuggestionsSupplier } from './suggestions';
|
|
||||||
|
|
||||||
export const plugin = new PanelPlugin(TracesPanel)
|
export const plugin = new PanelPlugin(TracesPanel)
|
||||||
.setMigrationHandler((panel) => {
|
.setMigrationHandler((panel) => {
|
||||||
@@ -44,4 +44,4 @@ export const plugin = new PanelPlugin(TracesPanel)
|
|||||||
category,
|
category,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new TracesSuggestionsSupplier());
|
.setSuggestionsSupplier(showDefaultSuggestion((ds) => ds.hasPreferredVisualisationType('trace')));
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
export class TracesSuggestionsSupplier {
|
|
||||||
getListWithDefaults(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
return builder.getListAppender<{}, {}>({
|
|
||||||
name: SuggestionName.Trace,
|
|
||||||
pluginId: 'traces',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
if (!builder.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataFrame = builder.data.series[0];
|
|
||||||
if (!dataFrame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (builder.data.series[0].meta?.preferredVisualisationType === 'trace') {
|
|
||||||
this.getListWithDefaults(builder).append({
|
|
||||||
name: SuggestionName.Trace,
|
|
||||||
score: VisualizationSuggestionScore.Best,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLikelyAscendingVector,
|
|
||||||
DataFrame,
|
DataFrame,
|
||||||
FieldMatcherID,
|
FieldMatcherID,
|
||||||
fieldMatchers,
|
fieldMatchers,
|
||||||
@@ -10,18 +9,17 @@ import {
|
|||||||
TimeRange,
|
TimeRange,
|
||||||
useDataLinksContext,
|
useDataLinksContext,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { config, PanelDataErrorView } from '@grafana/runtime';
|
import { PanelDataErrorView } from '@grafana/runtime';
|
||||||
import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext } from '@grafana/ui';
|
import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext } from '@grafana/ui';
|
||||||
import { TooltipHoverMode } from '@grafana/ui/internal';
|
import { TooltipHoverMode } from '@grafana/ui/internal';
|
||||||
import { XYFieldMatchers } from 'app/core/components/GraphNG/types';
|
import { XYFieldMatchers } from 'app/core/components/GraphNG/types';
|
||||||
import { preparePlotFrame } from 'app/core/components/GraphNG/utils';
|
import { preparePlotFrame } from 'app/core/components/GraphNG/utils';
|
||||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||||
import { findFieldIndex } from 'app/features/dimensions/utils';
|
|
||||||
|
|
||||||
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
|
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
|
||||||
import { prepareGraphableFields } from '../timeseries/utils';
|
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
import { Options } from './panelcfg.gen';
|
||||||
|
import { prepSeries } from './utils';
|
||||||
|
|
||||||
export const TrendPanel = ({
|
export const TrendPanel = ({
|
||||||
data,
|
data,
|
||||||
@@ -51,49 +49,7 @@ export const TrendPanel = ({
|
|||||||
return preparePlotFrame(frames, dimFields);
|
return preparePlotFrame(frames, dimFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
const info = useMemo(() => {
|
const info = useMemo(() => prepSeries(data.series, options.xField), [data.series, options.xField]);
|
||||||
if (data.series.length > 1) {
|
|
||||||
return {
|
|
||||||
warning: 'Only one frame is supported, consider adding a join transformation',
|
|
||||||
frames: data.series,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let frames = data.series;
|
|
||||||
let xFieldIdx: number | undefined;
|
|
||||||
if (options.xField) {
|
|
||||||
xFieldIdx = findFieldIndex(options.xField, frames[0]);
|
|
||||||
if (xFieldIdx == null) {
|
|
||||||
return {
|
|
||||||
warning: 'Unable to find field: ' + options.xField,
|
|
||||||
frames: data.series,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// first number field
|
|
||||||
// Perhaps we can/should support any ordinal rather than an error here
|
|
||||||
xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1;
|
|
||||||
if (xFieldIdx === -1) {
|
|
||||||
return {
|
|
||||||
warning: 'No numeric fields found for X axis',
|
|
||||||
frames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure values are ascending
|
|
||||||
if (xFieldIdx != null) {
|
|
||||||
const field = frames[0].fields[xFieldIdx];
|
|
||||||
if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) {
|
|
||||||
return {
|
|
||||||
warning: `Values must be in ascending order`,
|
|
||||||
frames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
|
|
||||||
}, [data.series, options.xField]);
|
|
||||||
|
|
||||||
if (info.warning || !info.frames) {
|
if (info.warning || !info.frames) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Field, FieldType, PanelPlugin } from '@grafana/data';
|
import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { commonOptionsBuilder } from '@grafana/ui';
|
import { GraphDrawStyle } from '@grafana/schema';
|
||||||
|
import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui';
|
||||||
import { optsWithHideZeros } from '@grafana/ui/internal';
|
import { optsWithHideZeros } from '@grafana/ui/internal';
|
||||||
|
|
||||||
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
|
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
|
||||||
|
|
||||||
import { TrendPanel } from './TrendPanel';
|
import { TrendPanel } from './TrendPanel';
|
||||||
import { FieldConfig, Options } from './panelcfg.gen';
|
import { FieldConfig, Options } from './panelcfg.gen';
|
||||||
import { TrendSuggestionsSupplier } from './suggestions';
|
import { prepSeries } from './utils';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
||||||
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false))
|
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false))
|
||||||
@@ -29,5 +30,47 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
|||||||
commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros);
|
commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros);
|
||||||
commonOptionsBuilder.addLegendOptions(builder);
|
commonOptionsBuilder.addLegendOptions(builder);
|
||||||
})
|
})
|
||||||
.setSuggestionsSupplier(new TrendSuggestionsSupplier());
|
.setSuggestionsSupplier((ds) => {
|
||||||
|
if (
|
||||||
|
!ds.rawFrames ||
|
||||||
|
ds.fieldCountByType(FieldType.number) < 2 ||
|
||||||
|
ds.rowCountTotal < 2 ||
|
||||||
|
ds.rowCountTotal < 2 ||
|
||||||
|
ds.frameCount > 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = prepSeries(ds.rawFrames);
|
||||||
|
if (info.warning || !info.frames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
score: VisualizationSuggestionScore.Good,
|
||||||
|
options: {
|
||||||
|
legend: {
|
||||||
|
calcs: [],
|
||||||
|
displayMode: LegendDisplayMode.Hidden,
|
||||||
|
placement: 'right',
|
||||||
|
showLegend: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
cardOptions: {
|
||||||
|
previewModifier: (s) => {
|
||||||
|
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
||||||
|
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
//.setDataSupport({ annotations: true, alertStates: true });
|
//.setDataSupport({ annotations: true, alertStates: true });
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
|
||||||
import { GraphDrawStyle, GraphFieldConfig, LegendDisplayMode } from '@grafana/schema';
|
|
||||||
import { SuggestionName } from 'app/types/suggestions';
|
|
||||||
|
|
||||||
import { Options } from './panelcfg.gen';
|
|
||||||
|
|
||||||
export class TrendSuggestionsSupplier {
|
|
||||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
|
||||||
const { dataSummary } = builder;
|
|
||||||
|
|
||||||
if (dataSummary.numberFieldCount < 2 || dataSummary.rowCountTotal < 2 || dataSummary.rowCountTotal < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Super basic
|
|
||||||
const list = builder.getListAppender<Options, GraphFieldConfig>({
|
|
||||||
name: SuggestionName.LineChart,
|
|
||||||
pluginId: 'trend',
|
|
||||||
options: {
|
|
||||||
legend: {
|
|
||||||
calcs: [],
|
|
||||||
displayMode: LegendDisplayMode.Hidden,
|
|
||||||
placement: 'right',
|
|
||||||
showLegend: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fieldConfig: {
|
|
||||||
defaults: {
|
|
||||||
custom: {},
|
|
||||||
},
|
|
||||||
overrides: [],
|
|
||||||
},
|
|
||||||
cardOptions: {
|
|
||||||
previewModifier: (s) => {
|
|
||||||
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
|
||||||
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { DataFrame, FieldType, isLikelyAscendingVector } from '@grafana/data';
|
||||||
|
import config from 'app/core/config';
|
||||||
|
import { findFieldIndex } from 'app/features/dimensions/utils';
|
||||||
|
|
||||||
|
import { prepareGraphableFields } from '../timeseries/utils';
|
||||||
|
|
||||||
|
export function prepSeries(frames: DataFrame[], xField?: string): { warning?: string; frames: DataFrame[] | null } {
|
||||||
|
if (frames.length > 1) {
|
||||||
|
return {
|
||||||
|
warning: 'Only one frame is supported, consider adding a join transformation',
|
||||||
|
frames: frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let xFieldIdx: number | undefined;
|
||||||
|
if (xField) {
|
||||||
|
xFieldIdx = findFieldIndex(xField, frames[0]);
|
||||||
|
if (xFieldIdx == null) {
|
||||||
|
return {
|
||||||
|
warning: 'Unable to find field: ' + xField,
|
||||||
|
frames: frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// first number field
|
||||||
|
// Perhaps we can/should support any ordinal rather than an error here
|
||||||
|
xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1;
|
||||||
|
if (xFieldIdx === -1) {
|
||||||
|
return {
|
||||||
|
warning: 'No numeric fields found for X axis',
|
||||||
|
frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure values are ascending
|
||||||
|
if (xFieldIdx != null) {
|
||||||
|
const field = frames[0].fields[xFieldIdx];
|
||||||
|
if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) {
|
||||||
|
return {
|
||||||
|
warning: `Values must be in ascending order`,
|
||||||
|
frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export enum SuggestionName {
|
|
||||||
LineChart = 'Line chart',
|
|
||||||
LineChartSmooth = 'Line chart smooth',
|
|
||||||
LineChartGradientColorScheme = 'Line chart with gradient color scheme',
|
|
||||||
AreaChart = 'Area chart',
|
|
||||||
AreaChartStacked = 'Area chart stacked',
|
|
||||||
AreaChartStackedPercent = 'Area chart 100% stacked',
|
|
||||||
BarChart = 'Bar chart',
|
|
||||||
BarChartGradientColorScheme = 'Bar chart with gradient color scheme',
|
|
||||||
BarChartStacked = 'Bar chart stacked',
|
|
||||||
BarChartStackedPercent = 'Bar chart 100% stacked',
|
|
||||||
BarChartHorizontal = 'Bar chart horizontal',
|
|
||||||
BarChartHorizontalStacked = 'Bar chart horizontal stacked',
|
|
||||||
BarChartHorizontalStackedPercent = 'Bar chart horizontal 100% stacked',
|
|
||||||
Candlestick = 'Candlestick',
|
|
||||||
PieChart = 'Pie chart',
|
|
||||||
PieChartDonut = 'Pie chart donut',
|
|
||||||
Stat = 'Stat',
|
|
||||||
StatColoredBackground = 'Stat colored background',
|
|
||||||
Gauge = 'Gauge',
|
|
||||||
GaugeCircular = 'Circular gauge',
|
|
||||||
GaugeNoThresholds = 'Gauge no thresholds',
|
|
||||||
BarGaugeBasic = 'Bar gauge basic',
|
|
||||||
BarGaugeLCD = 'Bar gauge LCD',
|
|
||||||
Table = 'Table',
|
|
||||||
StateTimeline = 'State timeline',
|
|
||||||
StatusHistory = 'Status history',
|
|
||||||
TextPanel = 'Text',
|
|
||||||
DashboardList = 'Dashboard list',
|
|
||||||
Logs = 'Logs',
|
|
||||||
FlameGraph = 'Flame graph',
|
|
||||||
Trace = 'Trace',
|
|
||||||
NodeGraph = 'Node graph',
|
|
||||||
}
|
|
||||||
@@ -483,6 +483,7 @@
|
|||||||
"noOptionsMessage-no-datasources-found": "Nebyly nalezeny žádné zdroje dat"
|
"noOptionsMessage-no-datasources-found": "Nebyly nalezeny žádné zdroje dat"
|
||||||
},
|
},
|
||||||
"alert-menu": {
|
"alert-menu": {
|
||||||
|
"analyze-rule": "",
|
||||||
"copy-link": "Kopírovat odkaz",
|
"copy-link": "Kopírovat odkaz",
|
||||||
"duplicate": "Duplikovat",
|
"duplicate": "Duplikovat",
|
||||||
"export": "Exportovat",
|
"export": "Exportovat",
|
||||||
@@ -3500,6 +3501,14 @@
|
|||||||
"label-negative-y": "Záporné Y"
|
"label-negative-y": "Záporné Y"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "",
|
||||||
|
"hz-stacked": "",
|
||||||
|
"hz-stacked-percent": "",
|
||||||
|
"vert-stacked": "",
|
||||||
|
"vert-stacked-percent": "",
|
||||||
|
"vertical": ""
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Vyžadovat místo z pravé strany",
|
"content-require-space-from-the-right-side": "Vyžadovat místo z pravé strany",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3544,6 +3553,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Zobrazit nevyplněnou oblast",
|
"name-show-unfilled-area": "Zobrazit nevyplněnou oblast",
|
||||||
"name-value-display": "Zobrazení hodnoty",
|
"name-value-display": "Zobrazení hodnoty",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "",
|
||||||
|
"lcd": ""
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Skryté",
|
"label-hidden": "Skryté",
|
||||||
"label-text-color": "Barva textu",
|
"label-text-color": "Barva textu",
|
||||||
@@ -4212,6 +4225,7 @@
|
|||||||
"clear": "Vymazat",
|
"clear": "Vymazat",
|
||||||
"collapse": "Sbalit",
|
"collapse": "Sbalit",
|
||||||
"disabled": "",
|
"disabled": "",
|
||||||
|
"discard": "",
|
||||||
"edit": "Upravit",
|
"edit": "Upravit",
|
||||||
"help": "Nápověda",
|
"help": "Nápověda",
|
||||||
"loading": "Načítání…",
|
"loading": "Načítání…",
|
||||||
@@ -4829,7 +4843,6 @@
|
|||||||
"variable": "{{type}} proměnná",
|
"variable": "{{type}} proměnná",
|
||||||
"variable-set": "Proměnné"
|
"variable-set": "Proměnné"
|
||||||
},
|
},
|
||||||
"open": "Otevřít podokno možností",
|
|
||||||
"row": {
|
"row": {
|
||||||
"header": {
|
"header": {
|
||||||
"hide": "Skrýt",
|
"hide": "Skrýt",
|
||||||
@@ -5182,6 +5195,7 @@
|
|||||||
"title-matched_other": "Shoda možností: {{count}}/{{totalCount}}"
|
"title-matched_other": "Shoda možností: {{count}}/{{totalCount}}"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
|
"pane-header": "",
|
||||||
"repeated-item": "Opakovat",
|
"repeated-item": "Opakovat",
|
||||||
"tree-item": {
|
"tree-item": {
|
||||||
"empty": "(prázdné)",
|
"empty": "(prázdné)",
|
||||||
@@ -5393,6 +5407,32 @@
|
|||||||
"share-public-dashboard-loader": {
|
"share-public-dashboard-loader": {
|
||||||
"loading-configuration": "Načítání konfigurace"
|
"loading-configuration": "Načítání konfigurace"
|
||||||
},
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard-options": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"edit-schema": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "",
|
||||||
|
"unsaved-modal": {
|
||||||
|
"text": "",
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"redo": "",
|
||||||
|
"snapshot": {
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"undo": ""
|
||||||
|
},
|
||||||
"solo-panel": {
|
"solo-panel": {
|
||||||
"loading-initializing-dashboard": "Načítání a inicializace nástěnky",
|
"loading-initializing-dashboard": "Načítání a inicializace nástěnky",
|
||||||
"title-not-found": "Panel s ID {{panelId}} nebyl nalezen"
|
"title-not-found": "Panel s ID {{panelId}} nebyl nalezen"
|
||||||
@@ -5496,11 +5536,8 @@
|
|||||||
"tooltip": "Tato nástěnka byla označena jen pro čtení"
|
"tooltip": "Tato nástěnka byla označena jen pro čtení"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"arrow": "Exportovat",
|
|
||||||
"title": "Exportovat",
|
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"as-code": "Exportovat jako kód",
|
"as-code": "Exportovat jako kód"
|
||||||
"json": "Exportovat jako JSON"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"more-save-options": "Další možnosti uložení",
|
"more-save-options": "Další možnosti uložení",
|
||||||
@@ -5552,6 +5589,9 @@
|
|||||||
"save-library-panel": "Uložit panel knihovny",
|
"save-library-panel": "Uložit panel knihovny",
|
||||||
"settings": "Nastavení nástěnky",
|
"settings": "Nastavení nástěnky",
|
||||||
"share-button": "Sdílet",
|
"share-button": "Sdílet",
|
||||||
|
"snapshot": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
"star-add-error": "",
|
"star-add-error": "",
|
||||||
"star-added": "",
|
"star-added": "",
|
||||||
"star-remove-error": "",
|
"star-remove-error": "",
|
||||||
@@ -5883,9 +5923,6 @@
|
|||||||
"name-values-separated-comma": "Hodnoty oddělené čárkou",
|
"name-values-separated-comma": "Hodnoty oddělené čárkou",
|
||||||
"selection-options": "Možnosti výběru"
|
"selection-options": "Možnosti výběru"
|
||||||
},
|
},
|
||||||
"dashboard-edit-pane-renderer": {
|
|
||||||
"outline": "Osnova"
|
|
||||||
},
|
|
||||||
"dashboard-link-form": {
|
"dashboard-link-form": {
|
||||||
"back-to-list": "Zpět na seznam",
|
"back-to-list": "Zpět na seznam",
|
||||||
"label-icon": "Ikona",
|
"label-icon": "Ikona",
|
||||||
@@ -7876,6 +7913,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "",
|
"arc": "",
|
||||||
"circular": "",
|
"circular": "",
|
||||||
|
"no-thresholds": "",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "",
|
"circular": "",
|
||||||
"simple": ""
|
"simple": ""
|
||||||
@@ -8173,13 +8211,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grafana": {
|
|
||||||
"dashboard": {
|
|
||||||
"edit-pane": {
|
|
||||||
"go-back": "Přejít zpět"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"grafana-data": {
|
"grafana-data": {
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"rangeutils": {
|
"rangeutils": {
|
||||||
@@ -11179,6 +11210,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Kobliha",
|
"label-donut": "Kobliha",
|
||||||
"label-pie": "Koláč"
|
"label-pie": "Koláč"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "",
|
||||||
|
"pie": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -13145,6 +13180,12 @@
|
|||||||
"label-same-as-value": "Stejné jako hodnota",
|
"label-same-as-value": "Stejné jako hodnota",
|
||||||
"label-standard": "Standardní"
|
"label-standard": "Standardní"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "",
|
||||||
|
"stat-discrete-values": "",
|
||||||
|
"stat-discrete-values-color-background": "",
|
||||||
|
"stat-single-string": ""
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Auto",
|
"label-auto": "Auto",
|
||||||
"label-center": "Střed"
|
"label-center": "Střed"
|
||||||
@@ -13484,7 +13525,8 @@
|
|||||||
"forwards-time-aria-label": "Přesunout časový rozsah dopředu",
|
"forwards-time-aria-label": "Přesunout časový rozsah dopředu",
|
||||||
"to": "do",
|
"to": "do",
|
||||||
"zoom-out-button": "Oddálit časový rozsah",
|
"zoom-out-button": "Oddálit časový rozsah",
|
||||||
"zoom-out-tooltip": ""
|
"zoom-out-tooltip": "",
|
||||||
|
"zoom-out-tooltip-new": ""
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Použít časový rozsah",
|
"apply": "Použít časový rozsah",
|
||||||
@@ -13586,6 +13628,16 @@
|
|||||||
"label-threshold": "Prahová hodnota"
|
"label-threshold": "Prahová hodnota"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "",
|
||||||
|
"area-stacked": "",
|
||||||
|
"area-stacked-percentage": "",
|
||||||
|
"bar": "",
|
||||||
|
"bar-stacked": "",
|
||||||
|
"bar-stacked-percent": "",
|
||||||
|
"line": "",
|
||||||
|
"line-smooth": ""
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Přidat časové pásmo",
|
"tooltip-add-timezone": "Přidat časové pásmo",
|
||||||
"tooltip-remove-timezone": "Odebrat časové pásmo"
|
"tooltip-remove-timezone": "Odebrat časové pásmo"
|
||||||
|
|||||||
@@ -477,6 +477,7 @@
|
|||||||
"noOptionsMessage-no-datasources-found": "Keine Datenquellen gefunden"
|
"noOptionsMessage-no-datasources-found": "Keine Datenquellen gefunden"
|
||||||
},
|
},
|
||||||
"alert-menu": {
|
"alert-menu": {
|
||||||
|
"analyze-rule": "",
|
||||||
"copy-link": "Link kopieren",
|
"copy-link": "Link kopieren",
|
||||||
"duplicate": "Duplikat",
|
"duplicate": "Duplikat",
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
@@ -3476,6 +3477,14 @@
|
|||||||
"label-negative-y": "Negativ Y"
|
"label-negative-y": "Negativ Y"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "",
|
||||||
|
"hz-stacked": "",
|
||||||
|
"hz-stacked-percent": "",
|
||||||
|
"vert-stacked": "",
|
||||||
|
"vert-stacked-percent": "",
|
||||||
|
"vertical": ""
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Leerzeichen von der rechten Seite erforderlich",
|
"content-require-space-from-the-right-side": "Leerzeichen von der rechten Seite erforderlich",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3520,6 +3529,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Ungefüllten Bereich anzeigen",
|
"name-show-unfilled-area": "Ungefüllten Bereich anzeigen",
|
||||||
"name-value-display": "Wertanzeige",
|
"name-value-display": "Wertanzeige",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "",
|
||||||
|
"lcd": ""
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Ausgeblendet",
|
"label-hidden": "Ausgeblendet",
|
||||||
"label-text-color": "Textfarbe",
|
"label-text-color": "Textfarbe",
|
||||||
@@ -4172,6 +4185,7 @@
|
|||||||
"clear": "Löschen",
|
"clear": "Löschen",
|
||||||
"collapse": "Einklappen",
|
"collapse": "Einklappen",
|
||||||
"disabled": "",
|
"disabled": "",
|
||||||
|
"discard": "",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"loading": "Wird geladen ...",
|
"loading": "Wird geladen ...",
|
||||||
@@ -4789,7 +4803,6 @@
|
|||||||
"variable": "{{type}} Variable",
|
"variable": "{{type}} Variable",
|
||||||
"variable-set": "Variable"
|
"variable-set": "Variable"
|
||||||
},
|
},
|
||||||
"open": "Optionsfenster öffnen",
|
|
||||||
"row": {
|
"row": {
|
||||||
"header": {
|
"header": {
|
||||||
"hide": "Ausblenden",
|
"hide": "Ausblenden",
|
||||||
@@ -5140,6 +5153,7 @@
|
|||||||
"title-matched_other": "Übereinstimmend {{count}}/{{totalCount}} Optionen"
|
"title-matched_other": "Übereinstimmend {{count}}/{{totalCount}} Optionen"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
|
"pane-header": "",
|
||||||
"repeated-item": "Wiederholen",
|
"repeated-item": "Wiederholen",
|
||||||
"tree-item": {
|
"tree-item": {
|
||||||
"empty": "(leer)",
|
"empty": "(leer)",
|
||||||
@@ -5351,6 +5365,32 @@
|
|||||||
"share-public-dashboard-loader": {
|
"share-public-dashboard-loader": {
|
||||||
"loading-configuration": "Konfiguration wird geladen"
|
"loading-configuration": "Konfiguration wird geladen"
|
||||||
},
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard-options": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"edit-schema": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "",
|
||||||
|
"unsaved-modal": {
|
||||||
|
"text": "",
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"redo": "",
|
||||||
|
"snapshot": {
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"undo": ""
|
||||||
|
},
|
||||||
"solo-panel": {
|
"solo-panel": {
|
||||||
"loading-initializing-dashboard": "Laden und Initialisieren des Dashboards",
|
"loading-initializing-dashboard": "Laden und Initialisieren des Dashboards",
|
||||||
"title-not-found": "Das Panel mit der ID {{panelId}} wurde nicht gefunden"
|
"title-not-found": "Das Panel mit der ID {{panelId}} wurde nicht gefunden"
|
||||||
@@ -5454,11 +5494,8 @@
|
|||||||
"tooltip": "Dieses Dashboard wurde als schreibgeschützt markiert"
|
"tooltip": "Dieses Dashboard wurde als schreibgeschützt markiert"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"arrow": "Exportieren",
|
|
||||||
"title": "Exportieren",
|
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"as-code": "Als Code exportieren",
|
"as-code": "Als Code exportieren"
|
||||||
"json": "Als JSON exportieren"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"more-save-options": "Weitere Speicheroptionen",
|
"more-save-options": "Weitere Speicheroptionen",
|
||||||
@@ -5510,6 +5547,9 @@
|
|||||||
"save-library-panel": "Bibliotheks-Panel speichern",
|
"save-library-panel": "Bibliotheks-Panel speichern",
|
||||||
"settings": "Dashboard-Einstellungen",
|
"settings": "Dashboard-Einstellungen",
|
||||||
"share-button": "Teilen",
|
"share-button": "Teilen",
|
||||||
|
"snapshot": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
"star-add-error": "",
|
"star-add-error": "",
|
||||||
"star-added": "",
|
"star-added": "",
|
||||||
"star-remove-error": "",
|
"star-remove-error": "",
|
||||||
@@ -5839,9 +5879,6 @@
|
|||||||
"name-values-separated-comma": "Werte werden durch Komma getrennt",
|
"name-values-separated-comma": "Werte werden durch Komma getrennt",
|
||||||
"selection-options": "Auswahloptionen"
|
"selection-options": "Auswahloptionen"
|
||||||
},
|
},
|
||||||
"dashboard-edit-pane-renderer": {
|
|
||||||
"outline": "Rahmen"
|
|
||||||
},
|
|
||||||
"dashboard-link-form": {
|
"dashboard-link-form": {
|
||||||
"back-to-list": "Zurück zur Liste",
|
"back-to-list": "Zurück zur Liste",
|
||||||
"label-icon": "Symbol",
|
"label-icon": "Symbol",
|
||||||
@@ -7824,6 +7861,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "",
|
"arc": "",
|
||||||
"circular": "",
|
"circular": "",
|
||||||
|
"no-thresholds": "",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "",
|
"circular": "",
|
||||||
"simple": ""
|
"simple": ""
|
||||||
@@ -8121,13 +8159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grafana": {
|
|
||||||
"dashboard": {
|
|
||||||
"edit-pane": {
|
|
||||||
"go-back": "Zurückgehen"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"grafana-data": {
|
"grafana-data": {
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"rangeutils": {
|
"rangeutils": {
|
||||||
@@ -11085,6 +11116,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Donut",
|
"label-donut": "Donut",
|
||||||
"label-pie": "Kreis"
|
"label-pie": "Kreis"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "",
|
||||||
|
"pie": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -13033,6 +13068,12 @@
|
|||||||
"label-same-as-value": "Identisch mit Wert",
|
"label-same-as-value": "Identisch mit Wert",
|
||||||
"label-standard": "Standard"
|
"label-standard": "Standard"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "",
|
||||||
|
"stat-discrete-values": "",
|
||||||
|
"stat-discrete-values-color-background": "",
|
||||||
|
"stat-single-string": ""
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Auto",
|
"label-auto": "Auto",
|
||||||
"label-center": "Mitte"
|
"label-center": "Mitte"
|
||||||
@@ -13370,7 +13411,8 @@
|
|||||||
"forwards-time-aria-label": "Zeitbereich nach vorne verschieben",
|
"forwards-time-aria-label": "Zeitbereich nach vorne verschieben",
|
||||||
"to": "bis",
|
"to": "bis",
|
||||||
"zoom-out-button": "Zeitbereich verkleinern",
|
"zoom-out-button": "Zeitbereich verkleinern",
|
||||||
"zoom-out-tooltip": ""
|
"zoom-out-tooltip": "",
|
||||||
|
"zoom-out-tooltip-new": ""
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Zeitbereich anwenden",
|
"apply": "Zeitbereich anwenden",
|
||||||
@@ -13472,6 +13514,16 @@
|
|||||||
"label-threshold": "Schwellenwert"
|
"label-threshold": "Schwellenwert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "",
|
||||||
|
"area-stacked": "",
|
||||||
|
"area-stacked-percentage": "",
|
||||||
|
"bar": "",
|
||||||
|
"bar-stacked": "",
|
||||||
|
"bar-stacked-percent": "",
|
||||||
|
"line": "",
|
||||||
|
"line-smooth": ""
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Zeitzone hinzufügen",
|
"tooltip-add-timezone": "Zeitzone hinzufügen",
|
||||||
"tooltip-remove-timezone": "Zeitzone entfernen"
|
"tooltip-remove-timezone": "Zeitzone entfernen"
|
||||||
|
|||||||
@@ -3477,6 +3477,14 @@
|
|||||||
"label-negative-y": "Negative Y"
|
"label-negative-y": "Negative Y"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "Horizontal bar chart",
|
||||||
|
"hz-stacked": "Horizontal bar chart - stacked",
|
||||||
|
"hz-stacked-percent": "Horizontal bar chart - stacked by percentage",
|
||||||
|
"vert-stacked": "Bar chart - stacked",
|
||||||
|
"vert-stacked-percent": "Bar chart - stacked by percentage",
|
||||||
|
"vertical": "Bar chart"
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Require space from the right side",
|
"content-require-space-from-the-right-side": "Require space from the right side",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3521,6 +3529,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Show unfilled area",
|
"name-show-unfilled-area": "Show unfilled area",
|
||||||
"name-value-display": "Value display",
|
"name-value-display": "Value display",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "Bar gauge",
|
||||||
|
"lcd": "Bar gauge - LCD"
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Hidden",
|
"label-hidden": "Hidden",
|
||||||
"label-text-color": "Text color",
|
"label-text-color": "Text color",
|
||||||
@@ -7849,6 +7861,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "Gauge",
|
"arc": "Gauge",
|
||||||
"circular": "Circular gauge",
|
"circular": "Circular gauge",
|
||||||
|
"no-thresholds": "Gauge - no thresholds",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "Glowing",
|
"circular": "Glowing",
|
||||||
"simple": "Simple"
|
"simple": "Simple"
|
||||||
@@ -11103,6 +11116,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Donut",
|
"label-donut": "Donut",
|
||||||
"label-pie": "Pie"
|
"label-pie": "Pie"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "Donut chart",
|
||||||
|
"pie": "Pie chart"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -11903,9 +11920,6 @@
|
|||||||
"source-code": "Source code"
|
"source-code": "Source code"
|
||||||
},
|
},
|
||||||
"repository-card": {
|
"repository-card": {
|
||||||
"get-repository-meta": {
|
|
||||||
"webhook": "Webhook"
|
|
||||||
},
|
|
||||||
"read-only-badge": "Read only",
|
"read-only-badge": "Read only",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"view": "View"
|
"view": "View"
|
||||||
@@ -13051,6 +13065,12 @@
|
|||||||
"label-same-as-value": "Same as Value",
|
"label-same-as-value": "Same as Value",
|
||||||
"label-standard": "Standard"
|
"label-standard": "Standard"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "Stat - color background",
|
||||||
|
"stat-discrete-values": "Stat - discrete values",
|
||||||
|
"stat-discrete-values-color-background": "Stat - discrete values - color background",
|
||||||
|
"stat-single-string": "Stat - single string"
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Auto",
|
"label-auto": "Auto",
|
||||||
"label-center": "Center"
|
"label-center": "Center"
|
||||||
@@ -13388,7 +13408,8 @@
|
|||||||
"forwards-time-aria-label": "Move time range forwards",
|
"forwards-time-aria-label": "Move time range forwards",
|
||||||
"to": "to",
|
"to": "to",
|
||||||
"zoom-out-button": "Zoom out time range",
|
"zoom-out-button": "Zoom out time range",
|
||||||
"zoom-out-tooltip": "Time range zoom out <br /> CTRL+Z"
|
"zoom-out-tooltip": "Time range zoom out <br /> CTRL+Z",
|
||||||
|
"zoom-out-tooltip-new": "Time range zoom out <br /> t -"
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Apply time range",
|
"apply": "Apply time range",
|
||||||
@@ -13490,6 +13511,16 @@
|
|||||||
"label-threshold": "Threshold"
|
"label-threshold": "Threshold"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "Area chart",
|
||||||
|
"area-stacked": "Area chart - stacked",
|
||||||
|
"area-stacked-percentage": "Area chart - stacked by percentage",
|
||||||
|
"bar": "Bar chart",
|
||||||
|
"bar-stacked": "Bar chart - stacked",
|
||||||
|
"bar-stacked-percent": "Bar chart - stacked by percentage",
|
||||||
|
"line": "Line chart",
|
||||||
|
"line-smooth": "Line chart - smooth"
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Add timezone",
|
"tooltip-add-timezone": "Add timezone",
|
||||||
"tooltip-remove-timezone": "Remove timezone"
|
"tooltip-remove-timezone": "Remove timezone"
|
||||||
|
|||||||
@@ -477,6 +477,7 @@
|
|||||||
"noOptionsMessage-no-datasources-found": "No se han encontrado fuentes de datos"
|
"noOptionsMessage-no-datasources-found": "No se han encontrado fuentes de datos"
|
||||||
},
|
},
|
||||||
"alert-menu": {
|
"alert-menu": {
|
||||||
|
"analyze-rule": "",
|
||||||
"copy-link": "Copiar enlace",
|
"copy-link": "Copiar enlace",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
@@ -3476,6 +3477,14 @@
|
|||||||
"label-negative-y": "Y negativa"
|
"label-negative-y": "Y negativa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "",
|
||||||
|
"hz-stacked": "",
|
||||||
|
"hz-stacked-percent": "",
|
||||||
|
"vert-stacked": "",
|
||||||
|
"vert-stacked-percent": "",
|
||||||
|
"vertical": ""
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Requerir espacio desde el lado derecho",
|
"content-require-space-from-the-right-side": "Requerir espacio desde el lado derecho",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3520,6 +3529,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Mostrar área sin rellenar",
|
"name-show-unfilled-area": "Mostrar área sin rellenar",
|
||||||
"name-value-display": "Visualización del valor",
|
"name-value-display": "Visualización del valor",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "",
|
||||||
|
"lcd": ""
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Oculto",
|
"label-hidden": "Oculto",
|
||||||
"label-text-color": "Color de texto",
|
"label-text-color": "Color de texto",
|
||||||
@@ -4172,6 +4185,7 @@
|
|||||||
"clear": "Borrar",
|
"clear": "Borrar",
|
||||||
"collapse": "Contraer",
|
"collapse": "Contraer",
|
||||||
"disabled": "",
|
"disabled": "",
|
||||||
|
"discard": "",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -4789,7 +4803,6 @@
|
|||||||
"variable": "{{type}} variable",
|
"variable": "{{type}} variable",
|
||||||
"variable-set": "Variables"
|
"variable-set": "Variables"
|
||||||
},
|
},
|
||||||
"open": "Abrir panel de opciones",
|
|
||||||
"row": {
|
"row": {
|
||||||
"header": {
|
"header": {
|
||||||
"hide": "Ocultar",
|
"hide": "Ocultar",
|
||||||
@@ -5140,6 +5153,7 @@
|
|||||||
"title-matched_other": "{{count}} coincidencias/{{totalCount}} opciones"
|
"title-matched_other": "{{count}} coincidencias/{{totalCount}} opciones"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
|
"pane-header": "",
|
||||||
"repeated-item": "Repetir",
|
"repeated-item": "Repetir",
|
||||||
"tree-item": {
|
"tree-item": {
|
||||||
"empty": "(vacío)",
|
"empty": "(vacío)",
|
||||||
@@ -5351,6 +5365,32 @@
|
|||||||
"share-public-dashboard-loader": {
|
"share-public-dashboard-loader": {
|
||||||
"loading-configuration": "Cargando configuración"
|
"loading-configuration": "Cargando configuración"
|
||||||
},
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard-options": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"edit-schema": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "",
|
||||||
|
"unsaved-modal": {
|
||||||
|
"text": "",
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"redo": "",
|
||||||
|
"snapshot": {
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"undo": ""
|
||||||
|
},
|
||||||
"solo-panel": {
|
"solo-panel": {
|
||||||
"loading-initializing-dashboard": "Cargando e iniciando el dashboard",
|
"loading-initializing-dashboard": "Cargando e iniciando el dashboard",
|
||||||
"title-not-found": "Panel con ID {{panelId}} no encontrado"
|
"title-not-found": "Panel con ID {{panelId}} no encontrado"
|
||||||
@@ -5454,11 +5494,8 @@
|
|||||||
"tooltip": "Este dashboard se ha marcado como de solo lectura"
|
"tooltip": "Este dashboard se ha marcado como de solo lectura"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"arrow": "Exportar",
|
|
||||||
"title": "Exportar",
|
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"as-code": "Exportar como código",
|
"as-code": "Exportar como código"
|
||||||
"json": "Exportar como JSON"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"more-save-options": "Más opciones de guardado",
|
"more-save-options": "Más opciones de guardado",
|
||||||
@@ -5510,6 +5547,9 @@
|
|||||||
"save-library-panel": "Guardar panel de biblioteca",
|
"save-library-panel": "Guardar panel de biblioteca",
|
||||||
"settings": "Ajustes del panel de control",
|
"settings": "Ajustes del panel de control",
|
||||||
"share-button": "Compartir",
|
"share-button": "Compartir",
|
||||||
|
"snapshot": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
"star-add-error": "",
|
"star-add-error": "",
|
||||||
"star-added": "",
|
"star-added": "",
|
||||||
"star-remove-error": "",
|
"star-remove-error": "",
|
||||||
@@ -5839,9 +5879,6 @@
|
|||||||
"name-values-separated-comma": "Valores separados por comas",
|
"name-values-separated-comma": "Valores separados por comas",
|
||||||
"selection-options": "Opciones de selección"
|
"selection-options": "Opciones de selección"
|
||||||
},
|
},
|
||||||
"dashboard-edit-pane-renderer": {
|
|
||||||
"outline": "Esquema"
|
|
||||||
},
|
|
||||||
"dashboard-link-form": {
|
"dashboard-link-form": {
|
||||||
"back-to-list": "Regresar a la lista",
|
"back-to-list": "Regresar a la lista",
|
||||||
"label-icon": "Icono",
|
"label-icon": "Icono",
|
||||||
@@ -7824,6 +7861,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "",
|
"arc": "",
|
||||||
"circular": "",
|
"circular": "",
|
||||||
|
"no-thresholds": "",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "",
|
"circular": "",
|
||||||
"simple": ""
|
"simple": ""
|
||||||
@@ -8121,13 +8159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grafana": {
|
|
||||||
"dashboard": {
|
|
||||||
"edit-pane": {
|
|
||||||
"go-back": "Volver"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"grafana-data": {
|
"grafana-data": {
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"rangeutils": {
|
"rangeutils": {
|
||||||
@@ -11085,6 +11116,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Anillo",
|
"label-donut": "Anillo",
|
||||||
"label-pie": "Círculo"
|
"label-pie": "Círculo"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "",
|
||||||
|
"pie": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -13033,6 +13068,12 @@
|
|||||||
"label-same-as-value": "Igual que el valor",
|
"label-same-as-value": "Igual que el valor",
|
||||||
"label-standard": "Estándar"
|
"label-standard": "Estándar"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "",
|
||||||
|
"stat-discrete-values": "",
|
||||||
|
"stat-discrete-values-color-background": "",
|
||||||
|
"stat-single-string": ""
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Auto",
|
"label-auto": "Auto",
|
||||||
"label-center": "Centro"
|
"label-center": "Centro"
|
||||||
@@ -13370,7 +13411,8 @@
|
|||||||
"forwards-time-aria-label": "Adelantar el intervalo de tiempo",
|
"forwards-time-aria-label": "Adelantar el intervalo de tiempo",
|
||||||
"to": "hasta",
|
"to": "hasta",
|
||||||
"zoom-out-button": "Reducir el intervalo de tiempo",
|
"zoom-out-button": "Reducir el intervalo de tiempo",
|
||||||
"zoom-out-tooltip": ""
|
"zoom-out-tooltip": "",
|
||||||
|
"zoom-out-tooltip-new": ""
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Aplicar intervalo de tiempo",
|
"apply": "Aplicar intervalo de tiempo",
|
||||||
@@ -13472,6 +13514,16 @@
|
|||||||
"label-threshold": "Umbral"
|
"label-threshold": "Umbral"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "",
|
||||||
|
"area-stacked": "",
|
||||||
|
"area-stacked-percentage": "",
|
||||||
|
"bar": "",
|
||||||
|
"bar-stacked": "",
|
||||||
|
"bar-stacked-percent": "",
|
||||||
|
"line": "",
|
||||||
|
"line-smooth": ""
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Añadir zona horaria",
|
"tooltip-add-timezone": "Añadir zona horaria",
|
||||||
"tooltip-remove-timezone": "Eliminar zona horaria"
|
"tooltip-remove-timezone": "Eliminar zona horaria"
|
||||||
|
|||||||
@@ -477,6 +477,7 @@
|
|||||||
"noOptionsMessage-no-datasources-found": "Aucune source de données trouvée"
|
"noOptionsMessage-no-datasources-found": "Aucune source de données trouvée"
|
||||||
},
|
},
|
||||||
"alert-menu": {
|
"alert-menu": {
|
||||||
|
"analyze-rule": "",
|
||||||
"copy-link": "Copier le lien",
|
"copy-link": "Copier le lien",
|
||||||
"duplicate": "Dupliquer",
|
"duplicate": "Dupliquer",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
@@ -3476,6 +3477,14 @@
|
|||||||
"label-negative-y": "Axe Y négatif"
|
"label-negative-y": "Axe Y négatif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "",
|
||||||
|
"hz-stacked": "",
|
||||||
|
"hz-stacked-percent": "",
|
||||||
|
"vert-stacked": "",
|
||||||
|
"vert-stacked-percent": "",
|
||||||
|
"vertical": ""
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Exiger un espace du côté droit",
|
"content-require-space-from-the-right-side": "Exiger un espace du côté droit",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3520,6 +3529,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Afficher la zone non remplie",
|
"name-show-unfilled-area": "Afficher la zone non remplie",
|
||||||
"name-value-display": "Affichage de la valeur",
|
"name-value-display": "Affichage de la valeur",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "",
|
||||||
|
"lcd": ""
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Masqué",
|
"label-hidden": "Masqué",
|
||||||
"label-text-color": "Couleur du texte",
|
"label-text-color": "Couleur du texte",
|
||||||
@@ -4172,6 +4185,7 @@
|
|||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"collapse": "Réduire",
|
"collapse": "Réduire",
|
||||||
"disabled": "",
|
"disabled": "",
|
||||||
|
"discard": "",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"loading": "Chargement en cours...",
|
"loading": "Chargement en cours...",
|
||||||
@@ -4789,7 +4803,6 @@
|
|||||||
"variable": "Variable {{type}}",
|
"variable": "Variable {{type}}",
|
||||||
"variable-set": "Variables"
|
"variable-set": "Variables"
|
||||||
},
|
},
|
||||||
"open": "Ouvrir le volet d'options",
|
|
||||||
"row": {
|
"row": {
|
||||||
"header": {
|
"header": {
|
||||||
"hide": "Masquer",
|
"hide": "Masquer",
|
||||||
@@ -5140,6 +5153,7 @@
|
|||||||
"title-matched_other": "{{count}} option(s)/{{totalCount}} mise(s) en correspondance"
|
"title-matched_other": "{{count}} option(s)/{{totalCount}} mise(s) en correspondance"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
|
"pane-header": "",
|
||||||
"repeated-item": "Répéter",
|
"repeated-item": "Répéter",
|
||||||
"tree-item": {
|
"tree-item": {
|
||||||
"empty": "(vide)",
|
"empty": "(vide)",
|
||||||
@@ -5351,6 +5365,32 @@
|
|||||||
"share-public-dashboard-loader": {
|
"share-public-dashboard-loader": {
|
||||||
"loading-configuration": "Chargement de la configuration"
|
"loading-configuration": "Chargement de la configuration"
|
||||||
},
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard-options": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"edit-schema": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "",
|
||||||
|
"unsaved-modal": {
|
||||||
|
"text": "",
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"redo": "",
|
||||||
|
"snapshot": {
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"undo": ""
|
||||||
|
},
|
||||||
"solo-panel": {
|
"solo-panel": {
|
||||||
"loading-initializing-dashboard": "Chargement et initialisation du tableau de bord",
|
"loading-initializing-dashboard": "Chargement et initialisation du tableau de bord",
|
||||||
"title-not-found": "Panneau avec l’ID {{panelId}} introuvable"
|
"title-not-found": "Panneau avec l’ID {{panelId}} introuvable"
|
||||||
@@ -5454,11 +5494,8 @@
|
|||||||
"tooltip": "Ce tableau de bord a été marqué comme étant en lecture seule"
|
"tooltip": "Ce tableau de bord a été marqué comme étant en lecture seule"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"arrow": "Exporter",
|
|
||||||
"title": "Exporter",
|
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"as-code": "Exporter en tant que code",
|
"as-code": "Exporter en tant que code"
|
||||||
"json": "Exporter en tant que JSON"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"more-save-options": "Plus d’options d’enregistrement",
|
"more-save-options": "Plus d’options d’enregistrement",
|
||||||
@@ -5510,6 +5547,9 @@
|
|||||||
"save-library-panel": "Enregistrer le panneau de la bibliothèque",
|
"save-library-panel": "Enregistrer le panneau de la bibliothèque",
|
||||||
"settings": "Paramètres du tableau de bord",
|
"settings": "Paramètres du tableau de bord",
|
||||||
"share-button": "Partager",
|
"share-button": "Partager",
|
||||||
|
"snapshot": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
"star-add-error": "",
|
"star-add-error": "",
|
||||||
"star-added": "",
|
"star-added": "",
|
||||||
"star-remove-error": "",
|
"star-remove-error": "",
|
||||||
@@ -5839,9 +5879,6 @@
|
|||||||
"name-values-separated-comma": "Valeurs séparées par des virgules",
|
"name-values-separated-comma": "Valeurs séparées par des virgules",
|
||||||
"selection-options": "Options de sélection"
|
"selection-options": "Options de sélection"
|
||||||
},
|
},
|
||||||
"dashboard-edit-pane-renderer": {
|
|
||||||
"outline": "Présentation"
|
|
||||||
},
|
|
||||||
"dashboard-link-form": {
|
"dashboard-link-form": {
|
||||||
"back-to-list": "Retour à la liste",
|
"back-to-list": "Retour à la liste",
|
||||||
"label-icon": "Icône",
|
"label-icon": "Icône",
|
||||||
@@ -7824,6 +7861,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "",
|
"arc": "",
|
||||||
"circular": "",
|
"circular": "",
|
||||||
|
"no-thresholds": "",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "",
|
"circular": "",
|
||||||
"simple": ""
|
"simple": ""
|
||||||
@@ -8121,13 +8159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grafana": {
|
|
||||||
"dashboard": {
|
|
||||||
"edit-pane": {
|
|
||||||
"go-back": "Retour"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"grafana-data": {
|
"grafana-data": {
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"rangeutils": {
|
"rangeutils": {
|
||||||
@@ -11085,6 +11116,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Anneau",
|
"label-donut": "Anneau",
|
||||||
"label-pie": "Circulaire"
|
"label-pie": "Circulaire"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "",
|
||||||
|
"pie": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -13033,6 +13068,12 @@
|
|||||||
"label-same-as-value": "Identique à la valeur",
|
"label-same-as-value": "Identique à la valeur",
|
||||||
"label-standard": "Standard"
|
"label-standard": "Standard"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "",
|
||||||
|
"stat-discrete-values": "",
|
||||||
|
"stat-discrete-values-color-background": "",
|
||||||
|
"stat-single-string": ""
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Auto",
|
"label-auto": "Auto",
|
||||||
"label-center": "Centré"
|
"label-center": "Centré"
|
||||||
@@ -13370,7 +13411,8 @@
|
|||||||
"forwards-time-aria-label": "Avancer la plage de temps",
|
"forwards-time-aria-label": "Avancer la plage de temps",
|
||||||
"to": "à",
|
"to": "à",
|
||||||
"zoom-out-button": "Dézoomer la plage de temps",
|
"zoom-out-button": "Dézoomer la plage de temps",
|
||||||
"zoom-out-tooltip": ""
|
"zoom-out-tooltip": "",
|
||||||
|
"zoom-out-tooltip-new": ""
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Appliquer la plage temporelle",
|
"apply": "Appliquer la plage temporelle",
|
||||||
@@ -13472,6 +13514,16 @@
|
|||||||
"label-threshold": "Seuil"
|
"label-threshold": "Seuil"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "",
|
||||||
|
"area-stacked": "",
|
||||||
|
"area-stacked-percentage": "",
|
||||||
|
"bar": "",
|
||||||
|
"bar-stacked": "",
|
||||||
|
"bar-stacked-percent": "",
|
||||||
|
"line": "",
|
||||||
|
"line-smooth": ""
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Ajouter le fuseau horaire",
|
"tooltip-add-timezone": "Ajouter le fuseau horaire",
|
||||||
"tooltip-remove-timezone": "Supprimer le fuseau horaire"
|
"tooltip-remove-timezone": "Supprimer le fuseau horaire"
|
||||||
|
|||||||
@@ -477,6 +477,7 @@
|
|||||||
"noOptionsMessage-no-datasources-found": "Nem található adatforrás"
|
"noOptionsMessage-no-datasources-found": "Nem található adatforrás"
|
||||||
},
|
},
|
||||||
"alert-menu": {
|
"alert-menu": {
|
||||||
|
"analyze-rule": "",
|
||||||
"copy-link": "Hivatkozás másolása",
|
"copy-link": "Hivatkozás másolása",
|
||||||
"duplicate": "Duplikálás",
|
"duplicate": "Duplikálás",
|
||||||
"export": "Exportálás",
|
"export": "Exportálás",
|
||||||
@@ -3476,6 +3477,14 @@
|
|||||||
"label-negative-y": "Negatív Y"
|
"label-negative-y": "Negatív Y"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"horizontal": "",
|
||||||
|
"hz-stacked": "",
|
||||||
|
"hz-stacked-percent": "",
|
||||||
|
"vert-stacked": "",
|
||||||
|
"vert-stacked-percent": "",
|
||||||
|
"vertical": ""
|
||||||
|
},
|
||||||
"tick-spacing-editor": {
|
"tick-spacing-editor": {
|
||||||
"content-require-space-from-the-right-side": "Hely szükséges a jobb oldalról",
|
"content-require-space-from-the-right-side": "Hely szükséges a jobb oldalról",
|
||||||
"gaps-options": {
|
"gaps-options": {
|
||||||
@@ -3520,6 +3529,10 @@
|
|||||||
},
|
},
|
||||||
"name-show-unfilled-area": "Kitöltetlen terület megjelenítése",
|
"name-show-unfilled-area": "Kitöltetlen terület megjelenítése",
|
||||||
"name-value-display": "Érték megjelenítése",
|
"name-value-display": "Érték megjelenítése",
|
||||||
|
"suggestions": {
|
||||||
|
"basic": "",
|
||||||
|
"lcd": ""
|
||||||
|
},
|
||||||
"value-display-options": {
|
"value-display-options": {
|
||||||
"label-hidden": "Rejtett",
|
"label-hidden": "Rejtett",
|
||||||
"label-text-color": "Szöveg színe",
|
"label-text-color": "Szöveg színe",
|
||||||
@@ -4172,6 +4185,7 @@
|
|||||||
"clear": "Törlés",
|
"clear": "Törlés",
|
||||||
"collapse": "Összecsukás",
|
"collapse": "Összecsukás",
|
||||||
"disabled": "",
|
"disabled": "",
|
||||||
|
"discard": "",
|
||||||
"edit": "Szerkesztés",
|
"edit": "Szerkesztés",
|
||||||
"help": "Súgó",
|
"help": "Súgó",
|
||||||
"loading": "Betöltés...",
|
"loading": "Betöltés...",
|
||||||
@@ -4789,7 +4803,6 @@
|
|||||||
"variable": "{{type}} változó",
|
"variable": "{{type}} változó",
|
||||||
"variable-set": "Változók"
|
"variable-set": "Változók"
|
||||||
},
|
},
|
||||||
"open": "Beállítások ablaktábla megnyitása",
|
|
||||||
"row": {
|
"row": {
|
||||||
"header": {
|
"header": {
|
||||||
"hide": "Elrejtés",
|
"hide": "Elrejtés",
|
||||||
@@ -5140,6 +5153,7 @@
|
|||||||
"title-matched_other": "{{totalCount}}/{{count}} egyező lehetőség"
|
"title-matched_other": "{{totalCount}}/{{count}} egyező lehetőség"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
|
"pane-header": "",
|
||||||
"repeated-item": "Ismétlés",
|
"repeated-item": "Ismétlés",
|
||||||
"tree-item": {
|
"tree-item": {
|
||||||
"empty": "(üres)",
|
"empty": "(üres)",
|
||||||
@@ -5351,6 +5365,32 @@
|
|||||||
"share-public-dashboard-loader": {
|
"share-public-dashboard-loader": {
|
||||||
"loading-configuration": "Konfiguráció betöltése"
|
"loading-configuration": "Konfiguráció betöltése"
|
||||||
},
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard-options": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"edit-schema": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "",
|
||||||
|
"unsaved-modal": {
|
||||||
|
"text": "",
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"title": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"redo": "",
|
||||||
|
"snapshot": {
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"undo": ""
|
||||||
|
},
|
||||||
"solo-panel": {
|
"solo-panel": {
|
||||||
"loading-initializing-dashboard": "Irányítópult betöltése és inicializálása",
|
"loading-initializing-dashboard": "Irányítópult betöltése és inicializálása",
|
||||||
"title-not-found": "A(z) {{panelId}} azonosítójú panel nem található"
|
"title-not-found": "A(z) {{panelId}} azonosítójú panel nem található"
|
||||||
@@ -5454,11 +5494,8 @@
|
|||||||
"tooltip": "Ez az irányítópult csak olvashatóként van megjelölve"
|
"tooltip": "Ez az irányítópult csak olvashatóként van megjelölve"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"arrow": "Exportálás",
|
|
||||||
"title": "Exportálás",
|
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"as-code": "Exportálás kódként",
|
"as-code": "Exportálás kódként"
|
||||||
"json": "Exportálás JSON-fájlként"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"more-save-options": "További mentési beállítások",
|
"more-save-options": "További mentési beállítások",
|
||||||
@@ -5510,6 +5547,9 @@
|
|||||||
"save-library-panel": "Könyvtárpanel mentése",
|
"save-library-panel": "Könyvtárpanel mentése",
|
||||||
"settings": "Irányítópult beállításai",
|
"settings": "Irányítópult beállításai",
|
||||||
"share-button": "Megosztás",
|
"share-button": "Megosztás",
|
||||||
|
"snapshot": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
"star-add-error": "",
|
"star-add-error": "",
|
||||||
"star-added": "",
|
"star-added": "",
|
||||||
"star-remove-error": "",
|
"star-remove-error": "",
|
||||||
@@ -5839,9 +5879,6 @@
|
|||||||
"name-values-separated-comma": "Értékek vesszővel elválasztva",
|
"name-values-separated-comma": "Értékek vesszővel elválasztva",
|
||||||
"selection-options": "Kijelölés beállításai"
|
"selection-options": "Kijelölés beállításai"
|
||||||
},
|
},
|
||||||
"dashboard-edit-pane-renderer": {
|
|
||||||
"outline": "Körvonal"
|
|
||||||
},
|
|
||||||
"dashboard-link-form": {
|
"dashboard-link-form": {
|
||||||
"back-to-list": "Vissza a listához",
|
"back-to-list": "Vissza a listához",
|
||||||
"label-icon": "Ikon",
|
"label-icon": "Ikon",
|
||||||
@@ -7824,6 +7861,7 @@
|
|||||||
"suggestions": {
|
"suggestions": {
|
||||||
"arc": "",
|
"arc": "",
|
||||||
"circular": "",
|
"circular": "",
|
||||||
|
"no-thresholds": "",
|
||||||
"style": {
|
"style": {
|
||||||
"circular": "",
|
"circular": "",
|
||||||
"simple": ""
|
"simple": ""
|
||||||
@@ -8121,13 +8159,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grafana": {
|
|
||||||
"dashboard": {
|
|
||||||
"edit-pane": {
|
|
||||||
"go-back": "Visszalépés"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"grafana-data": {
|
"grafana-data": {
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"rangeutils": {
|
"rangeutils": {
|
||||||
@@ -11085,6 +11116,10 @@
|
|||||||
"pie-chart-type-options": {
|
"pie-chart-type-options": {
|
||||||
"label-donut": "Fánkdiagram",
|
"label-donut": "Fánkdiagram",
|
||||||
"label-pie": "Kördiagram"
|
"label-pie": "Kördiagram"
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"donut": "",
|
||||||
|
"pie": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
@@ -13033,6 +13068,12 @@
|
|||||||
"label-same-as-value": "Értékkel megegyező",
|
"label-same-as-value": "Értékkel megegyező",
|
||||||
"label-standard": "Standard"
|
"label-standard": "Standard"
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"stat-color-background": "",
|
||||||
|
"stat-discrete-values": "",
|
||||||
|
"stat-discrete-values-color-background": "",
|
||||||
|
"stat-single-string": ""
|
||||||
|
},
|
||||||
"text-alignment-options": {
|
"text-alignment-options": {
|
||||||
"label-auto": "Automatikus",
|
"label-auto": "Automatikus",
|
||||||
"label-center": "Középre"
|
"label-center": "Középre"
|
||||||
@@ -13370,7 +13411,8 @@
|
|||||||
"forwards-time-aria-label": "Időtartomány mozgatása előre",
|
"forwards-time-aria-label": "Időtartomány mozgatása előre",
|
||||||
"to": "vége",
|
"to": "vége",
|
||||||
"zoom-out-button": "Időtartomány kicsinyítése",
|
"zoom-out-button": "Időtartomány kicsinyítése",
|
||||||
"zoom-out-tooltip": ""
|
"zoom-out-tooltip": "",
|
||||||
|
"zoom-out-tooltip-new": ""
|
||||||
},
|
},
|
||||||
"time-range": {
|
"time-range": {
|
||||||
"apply": "Időtartomány alkalmazása",
|
"apply": "Időtartomány alkalmazása",
|
||||||
@@ -13472,6 +13514,16 @@
|
|||||||
"label-threshold": "Küszöb"
|
"label-threshold": "Küszöb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"area": "",
|
||||||
|
"area-stacked": "",
|
||||||
|
"area-stacked-percentage": "",
|
||||||
|
"bar": "",
|
||||||
|
"bar-stacked": "",
|
||||||
|
"bar-stacked-percent": "",
|
||||||
|
"line": "",
|
||||||
|
"line-smooth": ""
|
||||||
|
},
|
||||||
"timezones-editor": {
|
"timezones-editor": {
|
||||||
"tooltip-add-timezone": "Időzóna hozzáadása",
|
"tooltip-add-timezone": "Időzóna hozzáadása",
|
||||||
"tooltip-remove-timezone": "Időzóna eltávolítása"
|
"tooltip-remove-timezone": "Időzóna eltávolítása"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user