Merge branch 'main' into plugin-dependency-install
This commit is contained in:
@@ -113,6 +113,7 @@ module.exports = [
|
||||
ignores: ['public/app/plugins/**', '**/*.story.tsx', '**/*.{test,spec}.{ts,tsx}', '**/__mocks__/', 'public/test'],
|
||||
rules: {
|
||||
'@grafana/no-untranslated-strings': 'error',
|
||||
'@grafana/no-translation-top-level': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+24
-3
@@ -549,6 +549,11 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx:5381": [
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "0"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "1"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/DataSourceSettings/types.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
@@ -3044,7 +3049,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
],
|
||||
"public/app/features/connections/tabs/ConnectData/Search/Search.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"public/app/features/connections/tabs/ConnectData/Search/index.tsx:5381": [
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
@@ -4568,9 +4574,18 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx:5381": [
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "0"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "1"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "2"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "3"]
|
||||
],
|
||||
"public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/explore/ShortLinkButtonMenu.tsx:5381": [
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "0"]
|
||||
],
|
||||
"public/app/features/explore/SupplementaryResultError.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
@@ -4929,8 +4944,14 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/inspector/InspectJSONTab.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "0"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "1"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "2"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "3"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "4"],
|
||||
[0, 0, 0, "Do not use the t() function outside of a component or function", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
|
||||
],
|
||||
"public/app/features/inspector/InspectStatsTab.tsx:5381": [
|
||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MinAlertLevel = warning
|
||||
|
||||
[*]
|
||||
[*.md]
|
||||
BasedOnStyles = Grafana
|
||||
TokenIgnores = (<http[^\n]+>+?), \*\*[^\n]+\*\*
|
||||
|
||||
@@ -25,7 +25,7 @@ cards:
|
||||
items:
|
||||
- title: Introduction
|
||||
href: ./fundamentals/
|
||||
description: Learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team’s ability to resolve issues quickly.
|
||||
description: Learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team's ability to resolve issues quickly.
|
||||
height: 24
|
||||
- title: Configure alert rules
|
||||
href: ./alerting-rules/
|
||||
@@ -55,7 +55,8 @@ Monitor your incoming metrics data or log entries and set up your Grafana Alerti
|
||||
|
||||
In this way, you eliminate the need for manual monitoring and provide a first line of defense against system outages or changes that could turn into major incidents.
|
||||
|
||||
Using Grafana Alerting, you create queries and expressions from multiple data sources — no matter where your data is stored — giving you the flexibility to combine your data and alert on your metrics and logs in new and unique ways. You can then create, manage, and take action on your alerts from a single, consolidated view, and improve your team’s ability to identify and resolve issues quickly.
|
||||
Using Grafana Alerting, you create queries and expressions from multiple data sources, no matter where you store your data, giving you the flexibility to combine your data and alert on your metrics and logs in new and unique ways.
|
||||
You can then create, manage, and take action on your alerts from a single, consolidated view, and improve your team's ability to identify and resolve issues quickly.
|
||||
|
||||
## Explore
|
||||
|
||||
|
||||
@@ -118,15 +118,17 @@ refs:
|
||||
|
||||
# Configure Grafana-managed alert rules
|
||||
|
||||
Grafana-managed rules can query data from multiple data sources in a single alert rule. They are the most flexible [alert rule type](ref:alert-rules). You can also add expressions to transform your data, set alert conditions, and images in alert notifications.
|
||||
Grafana-managed rules can query data from multiple data sources in a single alert rule.
|
||||
They're the most flexible [alert rule type](ref:alert-rules).
|
||||
You can also add expressions to transform your data, set alert conditions, and images in alert notifications.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
{{< admonition type="note" >}}
|
||||
In Grafana Cloud, the number of Grafana-managed alert rules you can create depends on your Grafana Cloud plan.
|
||||
|
||||
- Free Forever plan: You can create up to 100 free alert rules, with each alert rule having a maximum of 1000 alert instances.
|
||||
- All paid plans (Pro and Advanced): They have a soft limit of 2000 alert rules and support unlimited alert instances. To increase the limit, open a support ticket from the [Cloud portal](/docs/grafana-cloud/account-management/support/).
|
||||
|
||||
{{% /admonition %}}
|
||||
{{< /admonition >}}
|
||||
|
||||
To create or edit Grafana-managed alert rules, follow the instructions below. For a practical example, check out our [tutorial on getting started with Grafana alerting](http://grafana.com/tutorials/alerting-get-started/).
|
||||
|
||||
@@ -144,9 +146,11 @@ Only users with **Edit** permissions for the folder storing the rules can edit o
|
||||
|
||||
You can use default or advanced options for Grafana-managed alert rule creation. The default options streamline rule creation with a cleaner header and a single query and condition. For more complex rules, use advanced options to add multiple queries and expressions.
|
||||
|
||||
You can toggle between the two options. Once you have created an alert rule, the system defaults to your previous choice for the next alert rule.
|
||||
You can toggle between the two options.
|
||||
After you have created an alert rule, the system defaults to your previous choice for the next alert rule.
|
||||
|
||||
Switching from advanced to default may result in queries and expressions that cannot be converted. In this case, a warning message asks if you want to continue to reset to default settings.
|
||||
Switching from advanced to default may result in queries and expressions that can't be converted.
|
||||
In this case, a warning message asks if you want to continue to reset to default settings.
|
||||
|
||||
{{< docs/shared lookup="alerts/configure-alert-rule-name.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
@@ -169,9 +173,10 @@ You can toggle between **Default** and **Advanced** options. If the [Default vs.
|
||||
{{< collapse title="Advanced options" >}}
|
||||
|
||||
1. Select a data source.
|
||||
1. From the **Options** dropdown, specify a [time range](ref:time-units-and-relative-ranges).
|
||||
1. From the **Options** drop-down menu, specify a [time range](ref:time-units-and-relative-ranges).
|
||||
|
||||
Note that Grafana Alerting only supports fixed relative time ranges, for example, `now-24hr: now`. It does not support absolute time ranges: `2021-12-02 00:00:00 to 2021-12-05 23:59:592` or semi-relative time ranges: `now/d to: now`.
|
||||
Note that Grafana Alerting only supports fixed relative time ranges, for example, `now-24hr: now`.
|
||||
It doesn't support absolute time ranges: `2021-12-02 00:00:00 to 2021-12-05 23:59:592` or semi-relative time ranges: `now/d to: now`.
|
||||
|
||||
1. Add a query.
|
||||
|
||||
@@ -222,7 +227,8 @@ To do this, you need to make sure that your alert rule is in the right evaluatio
|
||||
|
||||
The pending period is the period in which an alert rule can be in breach of the condition until it fires.
|
||||
|
||||
Once a condition is met, the alert goes into the **Pending** state. If the condition remains active for the duration specified, the alert transitions to the **Firing** state, else it reverts to the **Normal** state.
|
||||
After a condition is met, the alert goes into the **Pending** state.
|
||||
If the condition remains active for the duration specified, the alert transitions to the **Firing** state, else it reverts to the **Normal** state.
|
||||
|
||||
1. Turn on pause alert notifications, if required.
|
||||
|
||||
@@ -253,7 +259,7 @@ Complete the following steps to set up notifications.
|
||||
|
||||
1. Choose this option to select an existing [contact point](ref:contact-points).
|
||||
|
||||
All notifications for this alert rule are sent to this contact point automatically and notification policies are not used.
|
||||
All notifications for this alert rule are sent to this contact point automatically and notification policies aren't used.
|
||||
|
||||
1. You can also optionally select a mute timing as well as groupings and timings to define when not to send notifications.
|
||||
|
||||
@@ -261,7 +267,8 @@ Complete the following steps to set up notifications.
|
||||
|
||||
1. Choose this option to use the [notification policy tree](ref:notification-policies) to handle alert notifications.
|
||||
|
||||
All notifications for this alert rule are managed by the notification policy tree, which routes alerts based on their labels. If an alert does not match a specific policy, the default notification policy applies, ensuring all alerts are handled.
|
||||
All notifications for this alert rule are managed by the notification policy tree, which routes alerts based on their labels.
|
||||
If an alert doesn't match a specific policy, the default notification policy applies, ensuring all alerts are handled.
|
||||
|
||||
1. Preview your alert instance routing set up.
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
|
||||
| `newLogsPanel` | Enables the new logs panel in Explore |
|
||||
| `pluginsCDNSyncLoader` | Load plugins from CDN synchronously |
|
||||
| `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"webpack-livereload-plugin": "3.0.2",
|
||||
"webpack-manifest-plugin": "5.0.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"webpack-subresource-integrity": "^5.2.0-rc.1",
|
||||
"webpackbar": "^7.0.0",
|
||||
"yaml": "^2.0.0",
|
||||
"yargs": "^17.5.1"
|
||||
|
||||
@@ -254,4 +254,5 @@ export interface FeatureToggles {
|
||||
alertingRuleVersionHistoryRestore?: boolean;
|
||||
newShareReportDrawer?: boolean;
|
||||
rendererDisableAppPluginsPreload?: boolean;
|
||||
assetSriChecks?: boolean;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,18 @@ const SearchTitle = ({ term }) => (
|
||||
|
||||
#### How to translate props or attributes
|
||||
|
||||
Right now, we only check if a string is wrapped up by the `Trans` tag. We currently do not apply this rule to props, attributes or similar, but we also ask for them to be translated with the `t()` function.
|
||||
This rule checks if a string is wrapped up by the `Trans` tag, or if certain props contain untranslated strings.
|
||||
We ask for such props to be translated with the `t()` function.
|
||||
|
||||
The below props are checked for untranslated strings:
|
||||
|
||||
- `label`
|
||||
- `description`
|
||||
- `placeholder`
|
||||
- `aria-label`
|
||||
- `title`
|
||||
- `text`
|
||||
- `tooltip`
|
||||
|
||||
```tsx
|
||||
// Bad ❌
|
||||
@@ -168,3 +179,50 @@ return <input type="value" placeholder={placeholder} />;
|
||||
```
|
||||
|
||||
Check more info about how translations work in Grafana in [Internationalization.md](https://github.com/grafana/grafana/blob/main/contribute/internationalization.md)
|
||||
|
||||
### `no-translation-top-level`
|
||||
|
||||
Ensure that `t()` translation method is not used at the top level of a file, outside of a component of method.
|
||||
This is to prevent calling the translation method before it's been instantiated.
|
||||
|
||||
This does not cause an error if a file is lazily loaded, but refactors can cause errors, and it can cause problems in tests.
|
||||
Fix the
|
||||
|
||||
```tsx
|
||||
// Bad ❌
|
||||
const someTranslatedText = t('some.key', 'Some text');
|
||||
const SomeComponent = () => {
|
||||
return <div title={someTranslatedText} />;
|
||||
};
|
||||
|
||||
// Good ✅
|
||||
const SomeComponent = () => {
|
||||
const someTranslatedText = t('some.key', 'Some text');
|
||||
return <div title={someTranslatedText} />;
|
||||
};
|
||||
|
||||
// Bad ❌
|
||||
const someConfigThatHasToBeShared = [{ foo: t('some.key', 'Some text') }];
|
||||
const SomeComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
{someConfigThatHasToBeShared.map((cfg) => {
|
||||
return <div>{cfg.foo}</div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Good ✅
|
||||
const someConfigThatHasToBeShared = () => [{ foo: t('some.key', 'Some text') }];
|
||||
const SomeComponent = () => {
|
||||
const configs = someConfigThatHasToBeShared();
|
||||
return (
|
||||
<div>
|
||||
{configs.map((cfg) => {
|
||||
return <div>{cfg.foo}</div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -2,6 +2,7 @@ const noAriaLabelSelectors = require('./rules/no-aria-label-e2e-selectors.cjs');
|
||||
const noBorderRadiusLiteral = require('./rules/no-border-radius-literal.cjs');
|
||||
const noUnreducedMotion = require('./rules/no-unreduced-motion.cjs');
|
||||
const noUntranslatedStrings = require('./rules/no-untranslated-strings.cjs');
|
||||
const noTranslationTopLevel = require('./rules/no-translation-top-level.cjs');
|
||||
const themeTokenUsage = require('./rules/theme-token-usage.cjs');
|
||||
|
||||
module.exports = {
|
||||
@@ -11,5 +12,6 @@ module.exports = {
|
||||
'no-border-radius-literal': noBorderRadiusLiteral,
|
||||
'theme-token-usage': themeTokenUsage,
|
||||
'no-untranslated-strings': noUntranslatedStrings,
|
||||
'no-translation-top-level': noTranslationTopLevel,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @ts-check
|
||||
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
|
||||
|
||||
/**
|
||||
* @typedef {import('@typescript-eslint/utils').TSESTree.Node} Node
|
||||
* @typedef {import('@typescript-eslint/utils').TSESLint.RuleContext<string, unknown[]>} RuleContext
|
||||
*/
|
||||
|
||||
const createRule = ESLintUtils.RuleCreator(
|
||||
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {RuleContext} context
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isInFunction = (context, node) => {
|
||||
const ancestors = context.sourceCode.getAncestors(node);
|
||||
return ancestors.some((anc) => {
|
||||
return [
|
||||
AST_NODE_TYPES.ArrowFunctionExpression,
|
||||
AST_NODE_TYPES.FunctionDeclaration,
|
||||
AST_NODE_TYPES.FunctionExpression,
|
||||
AST_NODE_TYPES.ClassDeclaration,
|
||||
].includes(anc.type);
|
||||
});
|
||||
};
|
||||
|
||||
const noTranslationTopLevel = createRule({
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === 't') {
|
||||
if (!isInFunction(context, node)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noMethodOutsideComponent',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
name: 'no-translation-top-level',
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Do not use translation functions outside of components',
|
||||
},
|
||||
messages: {
|
||||
noMethodOutsideComponent: 'Do not use the t() function outside of a component or function',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
});
|
||||
|
||||
module.exports = noTranslationTopLevel;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { RuleTester } from 'eslint';
|
||||
|
||||
import noTranslationTopLevel from '../rules/no-translation-top-level.cjs';
|
||||
|
||||
RuleTester.setDefaultConfig({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expectedErrorMessage = 'Do not use the t() function outside of a component or function';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('eslint no-translation-top-level', noTranslationTopLevel, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
function Component() {
|
||||
return <div>{t('some.key', 'Some text')}</div>;
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: `const foo = () => t('some.key', 'Some text');`,
|
||||
},
|
||||
{
|
||||
code: `const foo = ttt('some.key', 'Some text');`,
|
||||
},
|
||||
{
|
||||
code: `class Component {
|
||||
render() {
|
||||
return t('some.key', 'Some text');
|
||||
}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `const thing = t('some.key', 'Some text');`,
|
||||
errors: [{ message: expectedErrorMessage }],
|
||||
},
|
||||
{
|
||||
code: `const things = [t('some.key', 'Some text')];`,
|
||||
errors: [{ message: expectedErrorMessage }],
|
||||
},
|
||||
{
|
||||
code: `const objectThings = [{foo: t('some.key', 'Some text')}];`,
|
||||
errors: [{ message: expectedErrorMessage }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -18,6 +18,18 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
timezone: 'UTC',
|
||||
to: 'now',
|
||||
weekStart: 'monday',
|
||||
quickRanges: [
|
||||
{
|
||||
display: 'Last 6 hours',
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
{
|
||||
display: 'Last 3 days',
|
||||
from: 'now-3d',
|
||||
to: 'now',
|
||||
},
|
||||
],
|
||||
},
|
||||
annotations: [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
grafanaBackendServicesSquad codeowner = "@grafana/grafana-backend-services-squad"
|
||||
grafanaSearchAndStorageSquad codeowner = "@grafana/search-and-storage"
|
||||
grafanaPluginsPlatformSquad codeowner = "@grafana/plugins-platform-backend"
|
||||
grafanaFrontendOpsWG codeowner = "@grafana/frontend-ops"
|
||||
grafanaAsCodeSquad codeowner = "@grafana/grafana-as-code"
|
||||
identityAccessTeam codeowner = "@grafana/identity-access-team"
|
||||
grafanaObservabilityLogsSquad codeowner = "@grafana/observability-logs"
|
||||
|
||||
@@ -1775,6 +1775,13 @@ var (
|
||||
HideFromDocs: true,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "assetSriChecks",
|
||||
Description: "Enables SRI checks for Grafana JavaScript assets",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendOpsWG,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -235,3 +235,4 @@ alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true
|
||||
newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
|
||||
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
|
||||
assetSriChecks,experimental,@grafana/frontend-ops,false,false,true
|
||||
|
||||
|
@@ -950,4 +950,8 @@ const (
|
||||
// FlagRendererDisableAppPluginsPreload
|
||||
// Disable pre-loading app plugins when the request is coming from the renderer
|
||||
FlagRendererDisableAppPluginsPreload = "rendererDisableAppPluginsPreload"
|
||||
|
||||
// FlagAssetSriChecks
|
||||
// Enables SRI checks for Grafana JavaScript assets
|
||||
FlagAssetSriChecks = "assetSriChecks"
|
||||
)
|
||||
|
||||
@@ -598,6 +598,22 @@
|
||||
"codeowner": "@grafana/grafana-frontend-platform"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "assetSriChecks",
|
||||
"resourceVersion": "1739984409734",
|
||||
"creationTimestamp": "2025-02-19T15:56:59Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-02-19 17:00:09.734088 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables SRI checks for Grafana JavaScript assets",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/frontend-ops",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "authAPIAccessTokenAuth",
|
||||
|
||||
@@ -40,7 +40,7 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
|
||||
queryFn: async (rulesSourceIdentifier) => {
|
||||
const dataSourceUID = getDataSourceUID(rulesSourceIdentifier);
|
||||
if (!dataSourceUID) {
|
||||
return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) };
|
||||
return { error: new Error(`Unable to find data source for ${JSON.stringify(rulesSourceIdentifier)}`) };
|
||||
}
|
||||
|
||||
if (dataSourceUID === GrafanaRulesSourceSymbol) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AccessControlAction, FolderDTO } from 'app/types';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { mockDataSource, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
import { useFolder } from './useFolder';
|
||||
import { useIsRuleEditable } from './useIsRuleEditable';
|
||||
@@ -56,7 +57,9 @@ describe('useIsRuleEditable', () => {
|
||||
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
const { result } = renderHook(() => useIsRuleEditable(GRAFANA_RULES_SOURCE_NAME, mockRulerGrafanaRule()), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isRemovable).toBe(true);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getRulesPermissions } from '../utils/access-control';
|
||||
import { getDatasourceAPIUid } from '../utils/datasource';
|
||||
import { isGrafanaRulerRule } from '../utils/rules';
|
||||
|
||||
import { useFolder } from './useFolder';
|
||||
@@ -13,11 +12,16 @@ interface ResultBag {
|
||||
isEditable?: boolean;
|
||||
isRemovable?: boolean;
|
||||
loading: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
|
||||
const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
|
||||
uid: getDatasourceAPIUid(rulesSourceName),
|
||||
const {
|
||||
currentData: dsFeatures,
|
||||
isLoading,
|
||||
error,
|
||||
} = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
|
||||
rulesSourceName,
|
||||
});
|
||||
|
||||
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
|
||||
@@ -25,6 +29,17 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
|
||||
const rulePermission = getRulesPermissions(rulesSourceName);
|
||||
const { folder, loading } = useFolder(folderUID);
|
||||
|
||||
// handle discovery and data source errors
|
||||
if (error) {
|
||||
return {
|
||||
isEditable: false,
|
||||
isRemovable: false,
|
||||
loading: false,
|
||||
isRulerAvailable: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
return { isEditable: false, isRemovable: false, loading: false };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types';
|
||||
|
||||
import { SimpleConditionIdentifier } from './components/rule-editor/query-and-alert-condition/SimpleCondition';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers';
|
||||
|
||||
let nextDataSourceId = 1;
|
||||
@@ -686,7 +687,7 @@ export function getGrafanaRule(override?: Partial<CombinedRule>, rulerOverride?:
|
||||
namespace: {
|
||||
groups: [],
|
||||
name: 'Grafana',
|
||||
rulesSource: 'grafana',
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
},
|
||||
rulerRule: mockGrafanaRulerRule(rulerOverride),
|
||||
...override,
|
||||
|
||||
@@ -17,17 +17,30 @@ interface ExistingRuleEditorProps {
|
||||
}
|
||||
|
||||
export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorProps) {
|
||||
const {
|
||||
loading: loadingAlertRule,
|
||||
result: ruleWithLocation,
|
||||
error,
|
||||
} = useRuleWithLocation({ ruleIdentifier: identifier });
|
||||
const [queryParams] = useQueryParams();
|
||||
const isManualRestore = Boolean(queryParams.isManualRestore);
|
||||
|
||||
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
|
||||
const {
|
||||
loading: loadingAlertRule,
|
||||
result: ruleWithLocation,
|
||||
error: fetchRuleError,
|
||||
} = useRuleWithLocation({ ruleIdentifier: identifier });
|
||||
|
||||
const { isEditable, loading: loadingEditable } = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
|
||||
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
|
||||
const {
|
||||
isEditable,
|
||||
loading: loadingEditable,
|
||||
error: errorEditable,
|
||||
} = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
|
||||
|
||||
// error handling for fetching rule and rule RBAC
|
||||
if (fetchRuleError || errorEditable) {
|
||||
return (
|
||||
<Alert severity="error" title="Failed to load rule">
|
||||
{stringifyErrorLike(errorEditable ?? fetchRuleError)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const loading = loadingAlertRule || loadingEditable;
|
||||
|
||||
@@ -35,14 +48,6 @@ export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorPr
|
||||
return <LoadingPlaceholder text="Loading rule..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" title="Failed to load rule">
|
||||
{stringifyErrorLike(error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ruleWithLocation && !loading) {
|
||||
return <AlertWarning title="Rule not found">Sorry! This rule does not exist.</AlertWarning>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { setFolderResponse } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
|
||||
import { DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -12,6 +11,7 @@ import { AccessControlAction } from 'app/types';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
|
||||
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
|
||||
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { Annotation } from '../utils/constants';
|
||||
|
||||
@@ -151,3 +151,15 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
expect(postBody.interval).toBe('12m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data source managed rules', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleExternalWrite]);
|
||||
});
|
||||
|
||||
it('should show an error if the data source does not exist', async () => {
|
||||
renderRuleEditor('cri%24grafana-cloudd%24delete me%24delete me 3%24recording_rule_delete_2%24-476183141');
|
||||
expect(await screen.findByText(/unable to find data source/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { getDefaultVizPanel } from '../../utils/utils';
|
||||
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { BulkActionElement } from '../types/BulkActionElement';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
import { LayoutParent } from '../types/LayoutParent';
|
||||
|
||||
import { getEditOptions, renderActions } from './TabItemEditor';
|
||||
import { getEditOptions } from './TabItemEditor';
|
||||
import { TabItemRenderer } from './TabItemRenderer';
|
||||
import { TabItems } from './TabItems';
|
||||
import { TabsLayoutManager } from './TabsLayoutManager';
|
||||
@@ -56,14 +55,6 @@ export class TabItem
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return renderActions(this);
|
||||
}
|
||||
|
||||
public getParentLayout(): TabsLayoutManager {
|
||||
return sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
const layout = sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
layout.removeTab(this);
|
||||
@@ -73,7 +64,43 @@ export class TabItem
|
||||
return new TabItems(items.filter((item) => item instanceof TabItem));
|
||||
}
|
||||
|
||||
public onAddPanel(panel = getDefaultVizPanel()) {
|
||||
this.getLayout().addPanel(panel);
|
||||
}
|
||||
|
||||
public onAddTabBefore() {
|
||||
this._getParentLayout().addTabBefore(this);
|
||||
}
|
||||
|
||||
public onAddTabAfter() {
|
||||
this._getParentLayout().addTabAfter(this);
|
||||
}
|
||||
|
||||
public onMoveLeft() {
|
||||
this._getParentLayout().moveTabLeft(this);
|
||||
}
|
||||
|
||||
public onMoveRight() {
|
||||
this._getParentLayout().moveTabRight(this);
|
||||
}
|
||||
|
||||
public isFirstTab(): boolean {
|
||||
return this._getParentLayout().isFirstTab(this);
|
||||
}
|
||||
|
||||
public isLastTab(): boolean {
|
||||
return this._getParentLayout().isLastTab(this);
|
||||
}
|
||||
|
||||
public onChangeTitle(title: string) {
|
||||
this.setState({ title });
|
||||
}
|
||||
|
||||
public getParentLayout(): TabsLayoutManager {
|
||||
return this._getParentLayout();
|
||||
}
|
||||
|
||||
private _getParentLayout(): TabsLayoutManager {
|
||||
return sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Button, Input } from '@grafana/ui';
|
||||
import { Input } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
@@ -12,9 +13,12 @@ import { TabItem } from './TabItem';
|
||||
export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] {
|
||||
const tabOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.tabs-layout.tab-options.title', 'Tab options'),
|
||||
title: '',
|
||||
id: 'tab-options',
|
||||
isOpenDefault: true,
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader title={t('dashboard.tabs-layout.tab-options.title', 'Tab')} onDelete={() => model.onDelete()} />
|
||||
),
|
||||
}).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'),
|
||||
@@ -29,15 +33,6 @@ export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[]
|
||||
return [tabOptions, layoutOptions];
|
||||
}
|
||||
|
||||
export function renderActions(tab: TabItem): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => tab.onDelete()} icon="trash-alt" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabTitleInput({ tab }: { tab: TabItem }) {
|
||||
const { title } = tab.useState();
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Dropdown, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
interface Props {
|
||||
model: TabItem;
|
||||
}
|
||||
|
||||
export function TabItemMenu({ model }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<ToolbarButtonRow className={styles.container}>
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
overlay={() => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-panel', 'Panel')}
|
||||
label={t('dashboard.tabs-layout.tab.menu.add-panel', 'Panel')}
|
||||
onClick={() => model.onAddPanel()}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-tab-before', 'Tab before')}
|
||||
label={t('dashboard.tabs-layout.tab.menu.add-tab-above', 'Tab before')}
|
||||
onClick={() => model.onAddTabBefore()}
|
||||
/>
|
||||
<Menu.Item
|
||||
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-tab-after', 'Tab after')}
|
||||
label={t('dashboard.tabs-layout.tab.menu.add-tab-after', 'Tab after')}
|
||||
onClick={() => model.onAddTabAfter()}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<ToolbarButton
|
||||
aria-label={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
|
||||
title={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
|
||||
tooltip={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
|
||||
icon="plus"
|
||||
iconSize="xs"
|
||||
variant="default"
|
||||
>
|
||||
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
|
||||
</ToolbarButton>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
overlay={() => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
aria-label={t('dashboard.tabs-layout.tab.menu.move-left', 'Move tab left')}
|
||||
label={t('dashboard.tabs-layout.tab.menu.move-left', 'Move tab left')}
|
||||
onClick={() => model.onMoveLeft()}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
aria-label={t('dashboard.tabs-layout.tab.menu.move-right', 'Move tab right')}
|
||||
label={t('dashboard.tabs-layout.tab.menu.move-right', 'Move tab right')}
|
||||
onClick={() => model.onMoveRight()}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<ToolbarButton
|
||||
aria-label={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
|
||||
title={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
|
||||
tooltip={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
|
||||
icon="arrows-h"
|
||||
iconSize="md"
|
||||
variant="default"
|
||||
/>
|
||||
</Dropdown>
|
||||
</ToolbarButtonRow>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
container: css({
|
||||
gap: theme.spacing(0),
|
||||
flexShrink: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,41 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { GrafanaTheme2, locationUtil, textUtil } from '@grafana/data';
|
||||
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
|
||||
import { Tab, useElementSelection } from '@grafana/ui';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { Checkbox, clearButtonStyles, useElementSelection, useStyles2 } from '@grafana/ui';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { getFocusStyles } from '@grafana/ui/src/themes/mixins';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
const { title, key } = model.useState();
|
||||
const isClone = useMemo(() => isClonedKey(key!), [key]);
|
||||
const parentLayout = model.getParentLayout();
|
||||
const { tabs, currentTabIndex } = parentLayout.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing } = dashboard.useState();
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const { isSelected, onSelect } = useElementSelection(key);
|
||||
const myIndex = tabs.findIndex((tab) => tab === model);
|
||||
const isActive = myIndex === currentTabIndex;
|
||||
const location = useLocation();
|
||||
const href = locationUtil.getUrlForPartial(location, { tab: myIndex });
|
||||
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { tab: myIndex }));
|
||||
const styles = useStyles2(getStyles);
|
||||
const clearStyles = useStyles2(clearButtonStyles);
|
||||
|
||||
return (
|
||||
<Tab
|
||||
className={!isClone && isSelected ? 'dashboard-selected-element' : undefined}
|
||||
label={titleInterpolated}
|
||||
active={isActive}
|
||||
href={href}
|
||||
onPointerDown={(evt) => {
|
||||
if (isEditing && isActive && !isClone) {
|
||||
evt.stopPropagation();
|
||||
onSelect?.(evt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<div className={cx(styles.container, isSelected && 'dashboard-selected-element')} role="presentation">
|
||||
<span onPointerDown={onSelect}>
|
||||
<Checkbox value={!!isSelected} />
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={href}
|
||||
className={cx(clearStyles, styles.label, isActive ? styles.labelActive : styles.labelNotActive)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{titleInterpolated}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
container: css({
|
||||
listStyle: 'none',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
whiteSpace: 'nowrap',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
label: css({
|
||||
color: theme.colors.text.secondary,
|
||||
padding: theme.spacing(1, 1.5, 0.5),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
userSelect: 'none',
|
||||
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
|
||||
svg: {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
|
||||
'&:focus-visible': getFocusStyles(theme),
|
||||
|
||||
'&::before': {
|
||||
display: 'block',
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
bottom: 0,
|
||||
},
|
||||
}),
|
||||
labelNotActive: css({
|
||||
'a:hover, &:hover, &:focus': {
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
'&::before': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
},
|
||||
},
|
||||
}),
|
||||
labelActive: css({
|
||||
color: theme.colors.text.primary,
|
||||
overflow: 'hidden',
|
||||
|
||||
'&::before': {
|
||||
backgroundImage: theme.colors.gradients.brandHorizontal,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -8,7 +7,7 @@ import { EditableDashboardElementInfo } from '../types/EditableDashboardElement'
|
||||
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
import { getEditOptions, renderActions } from './TabItemsEditor';
|
||||
import { getEditOptions } from './TabItemsEditor';
|
||||
|
||||
export class TabItems implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
@@ -26,10 +25,6 @@ export class TabItems implements MultiSelectedEditableDashboardElement {
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return renderActions(this);
|
||||
}
|
||||
|
||||
public getTabs(): TabItem[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Button, Stack, Text } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
|
||||
import { TabItems } from './TabItems';
|
||||
|
||||
export function getEditOptions(_model: TabItems): OptionsPaneCategoryDescriptor[] {
|
||||
export function getEditOptions(model: TabItems): OptionsPaneCategoryDescriptor[] {
|
||||
const tabOptions = useMemo(() => {
|
||||
const tabs = model.getTabs();
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.edit-pane.tab.multi-select.options-header', 'Multi-selected Tab options'),
|
||||
title: ``,
|
||||
id: 'ms-tab-options',
|
||||
isOpenDefault: true,
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader
|
||||
title={t('dashboard.tabs-layout.multi-select.title', '{{length}} tabs selected', { length: tabs.length })}
|
||||
onDelete={() => model.onDelete()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, []);
|
||||
}, [model]);
|
||||
|
||||
return [tabOptions];
|
||||
}
|
||||
|
||||
export function renderActions(model: TabItems) {
|
||||
const tabs = model.getTabs();
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.tab.multi-select.selection-number">No. of tabs selected: </Trans>
|
||||
{tabs.length}
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,50 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
this.setState({ tabs, currentTabIndex: 0 });
|
||||
}
|
||||
|
||||
public addTabBefore(tab: TabItem) {
|
||||
const newTab = new TabItem();
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(tabs.indexOf(tab), 0, newTab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex });
|
||||
}
|
||||
|
||||
public addTabAfter(tab: TabItem) {
|
||||
const newTab = new TabItem();
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(tabs.indexOf(tab) + 1, 0, newTab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
|
||||
}
|
||||
|
||||
public moveTabLeft(tab: TabItem) {
|
||||
const currentIndex = this.state.tabs.indexOf(tab);
|
||||
if (currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(currentIndex, 1);
|
||||
tabs.splice(currentIndex - 1, 0, tab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex - 1 });
|
||||
}
|
||||
|
||||
public moveTabRight(tab: TabItem) {
|
||||
const currentIndex = this.state.tabs.indexOf(tab);
|
||||
if (currentIndex >= this.state.tabs.length - 1) {
|
||||
return;
|
||||
}
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(currentIndex, 1);
|
||||
tabs.splice(currentIndex + 1, 0, tab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
|
||||
}
|
||||
|
||||
public isFirstTab(tab: TabItem): boolean {
|
||||
return this.state.tabs[0] === tab;
|
||||
}
|
||||
|
||||
public isLastTab(tab: TabItem): boolean {
|
||||
return this.state.tabs[this.state.tabs.length - 1] === tab;
|
||||
}
|
||||
|
||||
public static createEmpty(): TabsLayoutManager {
|
||||
const tab = new TabItem();
|
||||
return new TabsLayoutManager({ tabs: [tab] });
|
||||
|
||||
+33
-8
@@ -1,9 +1,13 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
import { Divider, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
|
||||
import { TabItemMenu } from './TabItemMenu';
|
||||
import { TabsLayoutManager } from './TabsLayoutManager';
|
||||
|
||||
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
|
||||
@@ -11,13 +15,23 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
|
||||
const { tabs, currentTabIndex } = model.useState();
|
||||
const currentTab = tabs[currentTabIndex];
|
||||
const { layout } = currentTab.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing } = dashboard.useState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabsBar className={styles.tabsContainer}>
|
||||
{tabs.map((tab) => (
|
||||
<tab.Component model={tab} key={tab.state.key!} />
|
||||
))}
|
||||
<TabsBar className={styles.tabsWrapper}>
|
||||
<div className={styles.tabsRow}>
|
||||
<div className={styles.tabsContainer}>
|
||||
{tabs.map((tab, idx) => (
|
||||
<Fragment key={tab.state.key!}>
|
||||
{isEditing && idx > 0 && <Divider direction="vertical" />}
|
||||
<tab.Component model={tab} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{isEditing && <TabItemMenu model={currentTab} />}
|
||||
</div>
|
||||
</TabsBar>
|
||||
<TabContent className={styles.tabContentContainer}>
|
||||
{currentTab && <layout.Component model={layout} />}
|
||||
@@ -27,10 +41,21 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabsWrapper: css({
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
tabsRow: css({
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
}),
|
||||
tabsContainer: css({
|
||||
flexShrink: 1,
|
||||
padding: '2px 2px 0 2px',
|
||||
marginBottom: theme.spacing(1),
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
overflowX: 'scroll',
|
||||
overflowY: 'visible',
|
||||
paddingInline: theme.spacing(0.125),
|
||||
}),
|
||||
tabContentContainer: css({
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
+375
@@ -1,5 +1,380 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Given a scene with custom quick ranges should save quick ranges to save model 1`] = `
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana",
|
||||
},
|
||||
"enable": true,
|
||||
"hide": false,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard",
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "gdev-testdata",
|
||||
},
|
||||
"enable": true,
|
||||
"hide": false,
|
||||
"iconColor": "red",
|
||||
"name": "Enabled",
|
||||
"target": {
|
||||
"lines": 4,
|
||||
"refId": "Anno",
|
||||
"scenarioId": "annotations",
|
||||
},
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "gdev-testdata",
|
||||
},
|
||||
"enable": false,
|
||||
"hide": false,
|
||||
"iconColor": "yellow",
|
||||
"name": "Disabled",
|
||||
"target": {
|
||||
"lines": 5,
|
||||
"refId": "Anno",
|
||||
"scenarioId": "annotations",
|
||||
},
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "gdev-testdata",
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "dark-purple",
|
||||
"name": "Hidden",
|
||||
"target": {
|
||||
"lines": 6,
|
||||
"refId": "Anno",
|
||||
"scenarioId": "annotations",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 1,
|
||||
"graphTooltip": 1,
|
||||
"id": 1351,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A",
|
||||
},
|
||||
"description": "This is a simple time series graph",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic",
|
||||
},
|
||||
"custom": {
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineWidth": 2,
|
||||
},
|
||||
},
|
||||
"overrides": [],
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
},
|
||||
"id": 28,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none",
|
||||
},
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"alias": "series",
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A",
|
||||
},
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1,
|
||||
},
|
||||
],
|
||||
"title": "Simple time series graph ",
|
||||
"type": "timeseries",
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
},
|
||||
"id": 5,
|
||||
"panels": [],
|
||||
"title": "Row title",
|
||||
"type": "row",
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A",
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": [],
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 9,
|
||||
},
|
||||
"id": 29,
|
||||
"options": {},
|
||||
"targets": [
|
||||
{
|
||||
"alias": "series",
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A",
|
||||
},
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1,
|
||||
},
|
||||
],
|
||||
"title": "panel inside row",
|
||||
"type": "timeseries",
|
||||
},
|
||||
{
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": [],
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 11,
|
||||
"x": 12,
|
||||
"y": 9,
|
||||
},
|
||||
"id": 25,
|
||||
"options": {
|
||||
"code": {
|
||||
"language": "plaintext",
|
||||
"showLineNumbers": false,
|
||||
"showMiniMap": false,
|
||||
},
|
||||
"content": "content",
|
||||
"mode": "markdown",
|
||||
},
|
||||
"pluginVersion": "10.2.0-pre",
|
||||
"title": "Transparent text panel",
|
||||
"transparent": true,
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": "",
|
||||
"schemaVersion": 41,
|
||||
"tags": [
|
||||
"gdev",
|
||||
"graph-ng",
|
||||
"demo",
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"baseFilters": [],
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "wc2AL7L7k",
|
||||
},
|
||||
"filters": [],
|
||||
"name": "Filters",
|
||||
"type": "adhoc",
|
||||
},
|
||||
{
|
||||
"auto": true,
|
||||
"auto_count": 30,
|
||||
"auto_min": "10s",
|
||||
"current": {
|
||||
"text": "1m",
|
||||
"value": "1m",
|
||||
},
|
||||
"name": "intervalVar",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1m",
|
||||
"value": "1m",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "10m",
|
||||
"value": "10m",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "30m",
|
||||
"value": "30m",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1h",
|
||||
"value": "1h",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "6h",
|
||||
"value": "6h",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "12h",
|
||||
"value": "12h",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1d",
|
||||
"value": "1d",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "7d",
|
||||
"value": "7d",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "14d",
|
||||
"value": "14d",
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "30d",
|
||||
"value": "30d",
|
||||
},
|
||||
],
|
||||
"query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d",
|
||||
"refresh": 2,
|
||||
"type": "interval",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"text": [
|
||||
"a",
|
||||
],
|
||||
"value": [
|
||||
"a",
|
||||
],
|
||||
},
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "customVar",
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"text": "gdev-testdata",
|
||||
"value": "PD8C576611E62080A",
|
||||
},
|
||||
"includeAll": false,
|
||||
"name": "dsVar",
|
||||
"options": [],
|
||||
"query": "grafana-testdata-datasource",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"type": "datasource",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"text": "A",
|
||||
"value": "A",
|
||||
},
|
||||
"definition": "*",
|
||||
"includeAll": false,
|
||||
"name": "query0",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "*",
|
||||
"refId": "StandardVariableQuery",
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"type": "query",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"text": "test",
|
||||
"value": "test",
|
||||
},
|
||||
"hide": 2,
|
||||
"name": "constant",
|
||||
"query": "test",
|
||||
"skipUrlSync": true,
|
||||
"type": "constant",
|
||||
},
|
||||
],
|
||||
},
|
||||
"time": {
|
||||
"from": "now-5m",
|
||||
"to": "now",
|
||||
},
|
||||
"timepicker": {
|
||||
"quick_ranges": [
|
||||
{
|
||||
"display": "Last 6 hours",
|
||||
"from": "now-6h",
|
||||
"to": "now",
|
||||
},
|
||||
{
|
||||
"display": "Last 3 days",
|
||||
"from": "now-3d",
|
||||
"to": "now",
|
||||
},
|
||||
],
|
||||
"refresh_intervals": [
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d",
|
||||
],
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"title": "Dashboard to load1",
|
||||
"uid": "nP8rcffGkasd",
|
||||
"version": 2,
|
||||
"weekStart": "saturday",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`transformSceneToSaveModel Annotations should transform annotations to save model 1`] = `
|
||||
[
|
||||
{
|
||||
|
||||
+9
-4
@@ -32,8 +32,13 @@ import { RowRepeaterBehavior } from '../../scene/layout-default/RowRepeaterBehav
|
||||
import { RowActions } from '../../scene/layout-default/row-actions/RowActions';
|
||||
import { setDashboardPanelContext } from '../../scene/setDashboardPanelContext';
|
||||
import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager';
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { calculateGridItemDimensions, getVizPanelKeyForPanelId, isLibraryPanel } from '../../utils/utils';
|
||||
import { getOriginalKey, isClonedKey } from '../../utils/clone';
|
||||
import {
|
||||
calculateGridItemDimensions,
|
||||
getPanelIdForVizPanel,
|
||||
getVizPanelKeyForPanelId,
|
||||
isLibraryPanel,
|
||||
} from '../../utils/utils';
|
||||
import { GRID_ROW_HEIGHT } from '../const';
|
||||
|
||||
import { buildVizPanel } from './utils';
|
||||
@@ -143,7 +148,7 @@ function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, yOverride?: n
|
||||
const repeatVar = gridItem_.state.variableName;
|
||||
|
||||
// FIXME: which name should we use for the element reference, key or something else ?
|
||||
const elementName = gridItem_.state.body.state.key ?? 'DefaultName';
|
||||
const elementName = getVizPanelKeyForPanelId(getPanelIdForVizPanel(gridItem_.state.body));
|
||||
elementGridItem = {
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
@@ -266,7 +271,7 @@ function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record<
|
||||
}
|
||||
} else if (element.kind === 'GridLayoutRow') {
|
||||
const children = element.spec.elements.map((gridElement) => {
|
||||
const panel = elements[gridElement.spec.element.name];
|
||||
const panel = elements[getOriginalKey(gridElement.spec.element.name)];
|
||||
if (panel.kind === 'Panel') {
|
||||
return buildGridItem(gridElement.spec, panel, element.spec.y + GRID_ROW_HEIGHT + gridElement.spec.y);
|
||||
} else {
|
||||
|
||||
+1
-3
@@ -131,6 +131,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
expect(dashboardControls).toBeDefined();
|
||||
expect(dashboardControls.state.refreshPicker.state.intervals).toEqual(time.autoRefreshIntervals);
|
||||
expect(dashboardControls.state.hideTimeControls).toBe(time.hideTimepicker);
|
||||
expect(dashboardControls.state.timePicker.state.quickRanges).toEqual(dash.timeSettings.quickRanges);
|
||||
|
||||
// Variables
|
||||
const variables = scene.state?.$variables;
|
||||
@@ -223,9 +224,6 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
expect(dataLayers.state.annotationLayers[3].state.isEnabled).toBe(dash.annotations[3].spec.enable);
|
||||
expect(dataLayers.state.annotationLayers[3].state.isHidden).toBe(dash.annotations[3].spec.hide);
|
||||
|
||||
// To be implemented
|
||||
// expect(timePicker.state.ranges).toEqual(dash.timeSettings.quickRanges);
|
||||
|
||||
// VizPanel
|
||||
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
|
||||
expect(vizPanels).toHaveLength(3);
|
||||
|
||||
+3
-1
@@ -203,7 +203,9 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
}),
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({}),
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: dashboard.timeSettings.quickRanges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: dashboard.timeSettings.autoRefresh,
|
||||
intervals: dashboard.timeSettings.autoRefreshIntervals,
|
||||
|
||||
@@ -931,6 +931,36 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('When creating a snapshot dashboard scene', () => {
|
||||
it('should initialize a dashboard scene with SnapshotVariables', () => {
|
||||
const dashboard = {
|
||||
...defaultDashboard,
|
||||
title: 'With custom quick ranges',
|
||||
uid: 'test-uid',
|
||||
timepicker: {
|
||||
...defaultTimePickerConfig,
|
||||
quick_ranges: [
|
||||
{
|
||||
display: 'Last 6 hours',
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
{
|
||||
display: 'Last 3 days',
|
||||
from: 'now-3d',
|
||||
to: 'now',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const oldModel = new DashboardModel(dashboard);
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel, dashboard);
|
||||
|
||||
expect(scene.state.controls?.state.timePicker.state.quickRanges).toBe(dashboard.timepicker.quick_ranges);
|
||||
});
|
||||
});
|
||||
|
||||
function buildGridItemForTest(saveModel: Partial<Panel>): { gridItem: DashboardGridItem; vizPanel: VizPanel } {
|
||||
const gridItem = buildGridItemForPanel(new PanelModel(saveModel));
|
||||
if (gridItem instanceof DashboardGridItem) {
|
||||
|
||||
@@ -283,7 +283,9 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
||||
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({}),
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: oldModel.timepicker.quick_ranges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: oldModel.refresh,
|
||||
intervals: oldModel.timepicker.refresh_intervals,
|
||||
|
||||
@@ -1057,6 +1057,33 @@ describe('transformSceneToSaveModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Given a scene with custom quick ranges', () => {
|
||||
it('should save quick ranges to save model', () => {
|
||||
const dashboardWithCustomSettings = {
|
||||
...dashboard_to_load1,
|
||||
timepicker: {
|
||||
...dashboard_to_load1.timepicker,
|
||||
quick_ranges: [
|
||||
{
|
||||
display: 'Last 6 hours',
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
{
|
||||
display: 'Last 3 days',
|
||||
from: 'now-3d',
|
||||
to: 'now',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as DashboardDataDTO, meta: {} });
|
||||
const saveModel = transformSceneToSaveModel(scene);
|
||||
|
||||
expect(saveModel).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
export function buildGridItemFromPanelSchema(panel: Partial<Panel>) {
|
||||
return buildGridItemForPanel(new PanelModel(panel));
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
refresh_intervals: refreshPicker?.state.intervals,
|
||||
hidden: controlsState?.hideTimeControls,
|
||||
nowDelay: timeRange.UNSAFE_nowDelay,
|
||||
quick_ranges: controlsState?.timePicker.state.quickRanges,
|
||||
},
|
||||
defaultTimePickerConfig
|
||||
);
|
||||
|
||||
+12
@@ -404,6 +404,18 @@ function getMinimalSceneState(body: DashboardLayoutManager): Partial<DashboardSc
|
||||
timePicker: new SceneTimePicker({
|
||||
isOnCanvas: true,
|
||||
hidePicker: true,
|
||||
quickRanges: [
|
||||
{
|
||||
display: 'Last 6 hours',
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
{
|
||||
display: 'Last 3 days',
|
||||
from: 'now-3d',
|
||||
to: 'now',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
|
||||
weekStart: timeRange.weekStart,
|
||||
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
||||
nowDelay: timeRange.UNSAFE_nowDelay,
|
||||
quickRanges: controlsState?.timePicker.state.quickRanges,
|
||||
},
|
||||
// EOF time settings
|
||||
|
||||
|
||||
+14
-12
@@ -66,18 +66,20 @@ export default function TracePageActions(props: TracePageActionsProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.TracePageActions}>
|
||||
<div className={styles.feedbackContainer}>
|
||||
<Icon name="comment-alt-message" />
|
||||
<a
|
||||
href="https://forms.gle/RZDEx8ScyZNguDoC8"
|
||||
className={styles.feedback}
|
||||
title="Share your thoughts about tracing in Grafana."
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Give feedback
|
||||
</a>
|
||||
</div>
|
||||
{config.feedbackLinksEnabled && (
|
||||
<div className={styles.feedbackContainer}>
|
||||
<Icon name="comment-alt-message" />
|
||||
<a
|
||||
href="https://forms.gle/RZDEx8ScyZNguDoC8"
|
||||
className={styles.feedback}
|
||||
title="Share your thoughts about tracing in Grafana."
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Give feedback
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionButton
|
||||
onClick={copyTraceId}
|
||||
|
||||
@@ -1087,12 +1087,6 @@
|
||||
"options-header": "Multi-selected Row options",
|
||||
"selection-number": "No. of rows selected: {{length}}"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"multi-select": {
|
||||
"options-header": "Multi-selected Tab options",
|
||||
"selection-number": "No. of tabs selected: "
|
||||
}
|
||||
}
|
||||
},
|
||||
"editpane": {
|
||||
@@ -1250,12 +1244,27 @@
|
||||
},
|
||||
"tabs-layout": {
|
||||
"description": "Tabs layout",
|
||||
"menu": {
|
||||
"move-tab": "Move tab"
|
||||
},
|
||||
"multi-select": {
|
||||
"title": "{{length}} tabs selected"
|
||||
},
|
||||
"name": "Tabs",
|
||||
"tab": {
|
||||
"menu": {
|
||||
"add": "Add tab",
|
||||
"add-panel": "Panel",
|
||||
"add-tab-above": "Tab before",
|
||||
"add-tab-after": "Tab after",
|
||||
"add-tab-before": "Tab before",
|
||||
"move-left": "Move tab left",
|
||||
"move-right": "Move tab right"
|
||||
},
|
||||
"new": "New tab"
|
||||
},
|
||||
"tab-options": {
|
||||
"title": "Tab options",
|
||||
"title": "Tab",
|
||||
"title-option": "Title"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1087,12 +1087,6 @@
|
||||
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
|
||||
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"multi-select": {
|
||||
"options-header": "Mūľŧį-şęľęčŧęđ Ŧäþ őpŧįőʼnş",
|
||||
"selection-number": "Ńő. őƒ ŧäþş şęľęčŧęđ: "
|
||||
}
|
||||
}
|
||||
},
|
||||
"editpane": {
|
||||
@@ -1250,12 +1244,27 @@
|
||||
},
|
||||
"tabs-layout": {
|
||||
"description": "Ŧäþş ľäyőūŧ",
|
||||
"menu": {
|
||||
"move-tab": "Mővę ŧäþ"
|
||||
},
|
||||
"multi-select": {
|
||||
"title": "{{length}} ŧäþş şęľęčŧęđ"
|
||||
},
|
||||
"name": "Ŧäþş",
|
||||
"tab": {
|
||||
"menu": {
|
||||
"add": "Åđđ ŧäþ",
|
||||
"add-panel": "Päʼnęľ",
|
||||
"add-tab-above": "Ŧäþ þęƒőřę",
|
||||
"add-tab-after": "Ŧäþ äƒŧęř",
|
||||
"add-tab-before": "Ŧäþ þęƒőřę",
|
||||
"move-left": "Mővę ŧäþ ľęƒŧ",
|
||||
"move-right": "Mővę ŧäþ řįģĥŧ"
|
||||
},
|
||||
"new": "Ńęŵ ŧäþ"
|
||||
},
|
||||
"tab-options": {
|
||||
"title": "Ŧäþ őpŧįőʼnş",
|
||||
"title": "Ŧäþ",
|
||||
"title-option": "Ŧįŧľę"
|
||||
}
|
||||
},
|
||||
|
||||
+20
-7
@@ -355,13 +355,26 @@
|
||||
<!-- End Google Tag Manager -->
|
||||
[[end]]
|
||||
|
||||
[[range $asset := .Assets.JSFiles]]
|
||||
<script
|
||||
nonce="[[$.Nonce]]"
|
||||
src="[[$asset.FilePath]]"
|
||||
type="text/javascript"
|
||||
defer
|
||||
></script>
|
||||
[[if .Settings.FeatureToggles.assetSriChecks ]]
|
||||
[[range $asset := .Assets.JSFiles]]
|
||||
<script
|
||||
nonce="[[$.Nonce]]"
|
||||
src="[[$asset.FilePath]]"
|
||||
integrity="[[$asset.Integrity]]"
|
||||
crossorigin="anonymous"
|
||||
type="text/javascript"
|
||||
defer
|
||||
></script>
|
||||
[[end]]
|
||||
[[else]]
|
||||
[[range $asset := .Assets.JSFiles]]
|
||||
<script
|
||||
nonce="[[$.Nonce]]"
|
||||
src="[[$asset.FilePath]]"
|
||||
type="text/javascript"
|
||||
defer
|
||||
></script>
|
||||
[[end]]
|
||||
[[end]]
|
||||
|
||||
<script nonce="[[.Nonce]]">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @ts-check
|
||||
const webpack = require('webpack');
|
||||
|
||||
/** @typedef {import('webpack/lib/Compiler.js')} Compiler */
|
||||
|
||||
const PLUGIN_NAME = 'FeatureFlaggedSRIPlugin';
|
||||
const FEATURE_TOGGLE_WRAP = [
|
||||
'if (window.grafanaBootData && window.grafanaBootData.settings && window.grafanaBootData.settings.featureToggles && window.grafanaBootData.settings.featureToggles.assetSriChecks) {',
|
||||
'}',
|
||||
];
|
||||
|
||||
/**
|
||||
* Webpack plugin that wraps Webpack runtime integrity checks in a feature flag
|
||||
* This allows us to disable SRI checks in both the initial chunks but also in the
|
||||
* dynamically loaded chunks.
|
||||
*/
|
||||
class FeatureFlaggedSRIPlugin {
|
||||
/**
|
||||
* @param {Compiler} compiler The webpack compiler instance
|
||||
*/
|
||||
apply(compiler) {
|
||||
compiler.hooks.afterPlugins.tap(PLUGIN_NAME, (compiler) => {
|
||||
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
|
||||
compiler.hooks.thisCompilation.tap(
|
||||
{
|
||||
name: PLUGIN_NAME,
|
||||
},
|
||||
(compilation) => {
|
||||
const { mainTemplate } = compilation;
|
||||
mainTemplate.hooks.jsonpScript.tap(
|
||||
PLUGIN_NAME,
|
||||
/**
|
||||
* @param {string} source
|
||||
*/
|
||||
(source) => {
|
||||
if (source.includes('script.integrity =')) {
|
||||
logger.log('FeatureFlaggedSRIPlugin: Wrapping SRI checks in feature flag');
|
||||
return createFeatureFlaggedSRITemplate(source);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a template string wrapping the integrity and crossorigin attributes in a feature flag
|
||||
* @param {string} source The original webpack template source
|
||||
* @returns {string} The modified template source
|
||||
*/
|
||||
function createFeatureFlaggedSRITemplate(source) {
|
||||
const lines = source.split('\n');
|
||||
const integrityAttributeLineNumber = lines.findIndex((line) => line.includes('script.integrity ='));
|
||||
const [prefix, suffix] = FEATURE_TOGGLE_WRAP;
|
||||
return webpack.Template.asString([
|
||||
...lines.slice(0, integrityAttributeLineNumber),
|
||||
prefix,
|
||||
webpack.Template.indent(lines.slice(integrityAttributeLineNumber)),
|
||||
suffix,
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = FeatureFlaggedSRIPlugin;
|
||||
@@ -149,6 +149,7 @@ module.exports = (env = {}) => {
|
||||
new WebpackAssetsManifest({
|
||||
entrypoints: true,
|
||||
integrity: true,
|
||||
integrityHashes: ['sha384', 'sha512'],
|
||||
publicPath: true,
|
||||
}),
|
||||
new WebpackBar({
|
||||
|
||||
@@ -10,8 +10,10 @@ const { EnvironmentPlugin } = require('webpack');
|
||||
const WebpackAssetsManifest = require('webpack-assets-manifest');
|
||||
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
|
||||
const { merge } = require('webpack-merge');
|
||||
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
|
||||
|
||||
const getEnvConfig = require('./env-util.js');
|
||||
const FeatureFlaggedSRIPlugin = require('./plugins/FeatureFlaggedSriPlugin');
|
||||
const common = require('./webpack.common.js');
|
||||
const esbuildTargets = resolveToEsbuildTarget(browserslist(), { printUnknownTargets: false });
|
||||
|
||||
@@ -51,6 +53,9 @@ module.exports = (env = {}) =>
|
||||
}),
|
||||
],
|
||||
},
|
||||
output: {
|
||||
crossOriginLoading: 'anonymous',
|
||||
},
|
||||
optimization: {
|
||||
nodeEnv: 'production',
|
||||
minimize: parseInt(env.noMinify, 10) !== 1,
|
||||
@@ -70,6 +75,8 @@ module.exports = (env = {}) =>
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'grafana.[name].[contenthash].css',
|
||||
}),
|
||||
new SubresourceIntegrityPlugin(),
|
||||
new FeatureFlaggedSRIPlugin(),
|
||||
/**
|
||||
* I know we have two manifest plugins here.
|
||||
* WebpackManifestPlugin was only used in prod before and does not support integrity hashes
|
||||
@@ -77,6 +84,7 @@ module.exports = (env = {}) =>
|
||||
new WebpackAssetsManifest({
|
||||
entrypoints: true,
|
||||
integrity: true,
|
||||
integrityHashes: ['sha384', 'sha512'],
|
||||
publicPath: true,
|
||||
}),
|
||||
new WebpackManifestPlugin({
|
||||
|
||||
@@ -18449,6 +18449,7 @@ __metadata:
|
||||
webpack-livereload-plugin: "npm:3.0.2"
|
||||
webpack-manifest-plugin: "npm:5.0.0"
|
||||
webpack-merge: "npm:6.0.1"
|
||||
webpack-subresource-integrity: "npm:^5.2.0-rc.1"
|
||||
webpackbar: "npm:^7.0.0"
|
||||
whatwg-fetch: "npm:3.6.20"
|
||||
yaml: "npm:^2.0.0"
|
||||
@@ -31911,6 +31912,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack-subresource-integrity@npm:^5.2.0-rc.1":
|
||||
version: 5.2.0-rc.1
|
||||
resolution: "webpack-subresource-integrity@npm:5.2.0-rc.1"
|
||||
peerDependencies:
|
||||
html-webpack-plugin: ">= 5.0.0-beta.1 < 6"
|
||||
webpack: ^5.12.0
|
||||
peerDependenciesMeta:
|
||||
html-webpack-plugin:
|
||||
optional: true
|
||||
checksum: 10/a63e4e999812a753c70070457e6a2fb7e7ef1a05d8525a89796024fc56f26a0e56bd3e8f2b25e61f0ae969d627770a1fe326bebb58c296577d40c6a12aa4be37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack-virtual-modules@npm:^0.5.0":
|
||||
version: 0.5.0
|
||||
resolution: "webpack-virtual-modules@npm:0.5.0"
|
||||
|
||||
Reference in New Issue
Block a user