Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
e51c5796df
133 files changed
+2624
-908
No files matched your search
@@ -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"]
|
||||
@@ -3047,7 +3052,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"]
|
||||
@@ -4621,9 +4627,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"]
|
||||
],
|
||||
@@ -4982,8 +4997,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"]
|
||||
|
||||
+2
-1
@@ -622,7 +622,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/scripts/import_many_dashboards.sh @torkelo
|
||||
/scripts/mixin-check.sh @bergquist
|
||||
/scripts/openapi3/ @grafana/grafana-operator-experience-squad
|
||||
/scripts/prepare-packagejson.js @grafana/frontend-ops
|
||||
/scripts/prepare-npm-package.js @grafana/frontend-ops
|
||||
/scripts/protobuf-check.sh @grafana/plugins-platform-backend
|
||||
/scripts/stripnulls.sh @grafana/grafana-as-code
|
||||
/scripts/tag_release.sh @grafana/grafana-developer-enablement-squad
|
||||
@@ -820,6 +820,7 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/workflows/changelog.yml @zserge
|
||||
/.github/workflows/actions/changelog @zserge
|
||||
/.github/workflows/frontend-unit-tests.yml @grafana/grafana-frontend-platform
|
||||
/.github/workflows/frontend-lint.yml @grafana/grafana-frontend-platform
|
||||
|
||||
# Generated files not requiring owner approval
|
||||
/packages/grafana-data/src/types/featureToggles.gen.ts @grafanabot
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Lint Frontend
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release-*.*.*
|
||||
|
||||
jobs:
|
||||
verify-i18n:
|
||||
name: Verify i18n
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- run: yarn install --immutable --check-cache
|
||||
- run: |
|
||||
extract_error_message='::error::Extraction failed. Make sure that you have no dynamic translation phrases, such as "t(`preferences.theme.{themeID}`, themeName)" and that no translation key is used twice. Search the output for '[warning]' to find the offending file.'
|
||||
make i18n-extract || (echo "${extract_error_message}" && false)
|
||||
- run: |
|
||||
uncommited_error_message="::error::Translation extraction has not been committed. Please run 'make i18n-extract', commit the changes and push again."
|
||||
file_diff=$(git diff --dirstat public/locales)
|
||||
if [ -n "$file_diff" ]; then
|
||||
echo $file_diff
|
||||
echo "${uncommited_error_message}"
|
||||
exit 1
|
||||
fi
|
||||
prettier:
|
||||
name: Prettier
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- run: yarn install --immutable --check-cache
|
||||
- run: yarn run prettier:check
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- run: yarn install --immutable --check-cache
|
||||
- run: yarn run typecheck
|
||||
@@ -151,6 +151,7 @@ pkg/services/quota/quotaimpl/storage/storage.json
|
||||
# Ignoring frontend packages specifics
|
||||
/packages/grafana-ui/.yarn/.cache
|
||||
/packages/grafana-ui/.storybook/static
|
||||
/packages/grafana-ui/unstable
|
||||
/packages/**/dist
|
||||
/packages/**/compiled
|
||||
/packages/**/.rpt2_cache
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@ labels:
|
||||
{{< admonition type="note" >}}
|
||||
The Grafana Explore apps have changed to Grafana Drilldown apps.
|
||||
For example, Explore Logs is now Logs Drilldown.
|
||||
To learn more, read [Grafana Drilldown apps: the improved queryless experience known as the Explore apps](https://grafana.com/blog/2025/02/20/grafana-drilldown-apps-the-improved-queryless-experience-formerly-known-as-the-explore-apps/).
|
||||
To learn more, read [Grafana Drilldown apps: the improved queryless experience formerly known as the Explore apps](https://grafana.com/blog/2025/02/20/grafana-drilldown-apps-the-improved-queryless-experience-formerly-known-as-the-explore-apps/).
|
||||
{{< /admonition >}}
|
||||
@@ -83,6 +83,7 @@
|
||||
"@grafana/plugin-e2e": "1.17.1",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@manypkg/get-packages": "^2.2.0",
|
||||
"@npmcli/package-json": "^5.2.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
|
||||
"@react-types/button": "3.10.2",
|
||||
@@ -247,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"
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -29,10 +29,10 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"clean": "rimraf ./dist ./compiled ./unstable ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
"prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json && rimraf ./unstable"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "7.0.1",
|
||||
|
||||
@@ -11,5 +11,23 @@ export default [
|
||||
plugins,
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-data')],
|
||||
},
|
||||
{
|
||||
input: 'src/unstable.ts',
|
||||
plugins,
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-data')],
|
||||
},
|
||||
tsDeclarationOutput(pkg),
|
||||
tsDeclarationOutput(pkg, {
|
||||
input: './compiled/unstable.d.ts',
|
||||
output: [
|
||||
{
|
||||
file: './dist/cjs/unstable.d.cts',
|
||||
format: 'cjs',
|
||||
},
|
||||
{
|
||||
file: './dist/esm/unstable.d.mts',
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { MonoTypeOperatorFunction, Observable, of } from 'rxjs';
|
||||
import { map, mergeMap } from 'rxjs/operators';
|
||||
|
||||
@@ -12,9 +13,6 @@ import {
|
||||
import { getFrameMatchers } from './matchers';
|
||||
import { standardTransformersRegistry, TransformerRegistryItem } from './standardTransformersRegistry';
|
||||
|
||||
// when running within Scenes, we can skip var interpolation, since it's already handled upstream
|
||||
const isScenes = window.__grafanaSceneContext != null;
|
||||
|
||||
const getOperator =
|
||||
(config: DataTransformerConfig, ctx: DataTransformContext): MonoTypeOperatorFunction<DataFrame[]> =>
|
||||
(source) => {
|
||||
@@ -27,9 +25,12 @@ const getOperator =
|
||||
const defaultOptions = info.transformation.defaultOptions ?? {};
|
||||
const options = { ...defaultOptions, ...config.options };
|
||||
|
||||
// when running within Scenes, we can skip var interpolation, since it's already handled upstream
|
||||
const isScenes = window.__grafanaSceneContext != null;
|
||||
|
||||
const interpolated = isScenes
|
||||
? options
|
||||
: deepIterate(options, (v) => {
|
||||
: deepIterate(cloneDeep(options), (v) => {
|
||||
if (typeof v === 'string') {
|
||||
return ctx.interpolate(v);
|
||||
}
|
||||
|
||||
@@ -254,4 +254,5 @@ export interface FeatureToggles {
|
||||
alertingRuleVersionHistoryRestore?: boolean;
|
||||
newShareReportDrawer?: boolean;
|
||||
rendererDisableAppPluginsPreload?: boolean;
|
||||
assetSriChecks?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* THESE APIS MUST NOT BE USED IN COMMUNITY PLUGINS.
|
||||
*
|
||||
* Unstable APIs are still in development and are subject to breaking changes
|
||||
* at any point, like feature flags but for APIS. They must only be used in
|
||||
* Grafana core and internal plugins where we can coordinate changes.
|
||||
*
|
||||
* Once mature, they will be moved to the main export, be available to plugins via the standard import path,
|
||||
* and be subject to the standard policies
|
||||
*/
|
||||
|
||||
// This is a dummy export so typescript doesn't error importing an "empty module"
|
||||
export const unstable = {};
|
||||
@@ -19,9 +19,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -35,7 +35,7 @@
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"semver": "7.7.0",
|
||||
"semver": "^7.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -19,9 +19,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -35,7 +35,7 @@
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.mts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"./LICENSE_AGPL"
|
||||
],
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -32,7 +32,7 @@
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -31,10 +31,10 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"clean": "rimraf ./dist ./compiled ./unstable ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
"prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json && rimraf ./unstable"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "11.6.0-pre",
|
||||
|
||||
@@ -11,5 +11,23 @@ export default [
|
||||
plugins,
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-runtime')],
|
||||
},
|
||||
{
|
||||
input: 'src/unstable.ts',
|
||||
plugins,
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-runtime')],
|
||||
},
|
||||
tsDeclarationOutput(pkg),
|
||||
tsDeclarationOutput(pkg, {
|
||||
input: './compiled/unstable.d.ts',
|
||||
output: [
|
||||
{
|
||||
file: './dist/cjs/unstable.d.cts',
|
||||
format: 'cjs',
|
||||
},
|
||||
{
|
||||
file: './dist/esm/unstable.d.mts',
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* THESE APIS MUST NOT BE USED IN COMMUNITY PLUGINS.
|
||||
*
|
||||
* Unstable APIs are still in development and are subject to breaking changes
|
||||
* at any point, like feature flags but for APIS. They must only be used in
|
||||
* Grafana core and internal plugins where we can coordinate changes.
|
||||
*
|
||||
* Once mature, they will be moved to the main export, be available to plugins via the standard import path,
|
||||
* and be subject to the standard policies
|
||||
*/
|
||||
|
||||
// This is a dummy export so typescript doesn't error importing an "empty module"
|
||||
export const unstable = {};
|
||||
@@ -16,9 +16,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -32,7 +32,7 @@
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "./dist/cjs/index.cjs",
|
||||
"module": "./dist/esm/index.mjs",
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
@@ -34,12 +34,12 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
|
||||
"clean": "rimraf ./dist ./compiled ./package.tgz",
|
||||
"clean": "rimraf ./dist ./compiled ./unstable ./package.tgz",
|
||||
"storybook": "storybook dev -p 9001 -c .storybook --no-open",
|
||||
"storybook:build": "storybook build -o ./dist/storybook -c .storybook",
|
||||
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
|
||||
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-packagejson.js",
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
"prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js",
|
||||
"postpack": "mv package.json.bak package.json && rimraf ./unstable"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
|
||||
@@ -25,5 +25,30 @@ export default [
|
||||
],
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-ui')],
|
||||
},
|
||||
{
|
||||
input: 'src/unstable.ts',
|
||||
plugins: [
|
||||
...plugins,
|
||||
svg({ stringify: true }),
|
||||
copy({
|
||||
targets: [{ src: iconSrcPaths, dest: './dist/public/' }],
|
||||
flatten: false,
|
||||
}),
|
||||
],
|
||||
output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-ui')],
|
||||
},
|
||||
tsDeclarationOutput(pkg),
|
||||
tsDeclarationOutput(pkg, {
|
||||
input: './compiled/unstable.d.ts',
|
||||
output: [
|
||||
{
|
||||
file: './dist/cjs/unstable.d.cts',
|
||||
format: 'cjs',
|
||||
},
|
||||
{
|
||||
file: './dist/esm/unstable.d.mts',
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file contains the common parts of the rollup configuration that are shared across multiple packages.
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
import esbuild from 'rollup-plugin-esbuild';
|
||||
import { nodeExternals } from 'rollup-plugin-node-externals';
|
||||
@@ -28,6 +28,7 @@ export function cjsOutput(pkg) {
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
dir: dirname(pkg.publishConfig.main),
|
||||
entryFileNames: '[name].cjs',
|
||||
esModule: true,
|
||||
interop: 'compat',
|
||||
};
|
||||
@@ -39,6 +40,7 @@ export function esmOutput(pkg, pkgName) {
|
||||
format: 'esm',
|
||||
sourcemap: true,
|
||||
dir: dirname(pkg.publishConfig.module),
|
||||
entryFileNames: '[name].mjs',
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: resolve(projectCwd, `packages/${pkgName}/src`),
|
||||
};
|
||||
@@ -49,10 +51,16 @@ export function tsDeclarationOutput(pkg, overrides = {}) {
|
||||
return {
|
||||
input: './compiled/index.d.ts',
|
||||
plugins: [dts()],
|
||||
output: {
|
||||
file: pkg.publishConfig.types,
|
||||
format: 'es',
|
||||
},
|
||||
output: [
|
||||
{
|
||||
file: pkg.publishConfig.types,
|
||||
format: 'cjs',
|
||||
},
|
||||
{
|
||||
file: join(dirname(pkg.publishConfig.module), 'index.d.mts'),
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
// ToDashboardErrorResponse returns a different response status according to the dashboard error type
|
||||
@@ -39,5 +41,9 @@ func ToDashboardErrorResponse(ctx context.Context, pluginStore pluginstore.Store
|
||||
return response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "plugin-dashboard", "message": message})
|
||||
}
|
||||
|
||||
if apierrors.IsRequestEntityTooLargeError(err) {
|
||||
return response.Error(http.StatusRequestEntityTooLarge, fmt.Sprintf("Dashboard is too large, max is %d MB", apiserver.MaxRequestBodyBytes/1024/1024), err)
|
||||
}
|
||||
|
||||
return response.Error(http.StatusInternalServerError, "Failed to save dashboard", err)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dtos
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
@@ -31,7 +32,7 @@ type Folder struct {
|
||||
|
||||
// When the folder belongs to a repository
|
||||
// NOTE: this is only populated when folders are managed by unified storage
|
||||
Repository string `json:"repository,omitempty"`
|
||||
ManagedBy utils.ManagerKind `json:"managedBy,omitempty"`
|
||||
}
|
||||
|
||||
type FolderSearchHit struct {
|
||||
@@ -42,5 +43,5 @@ type FolderSearchHit struct {
|
||||
|
||||
// When the folder belongs to a repository
|
||||
// NOTE: this is only populated when folders are managed by unified storage
|
||||
Repository string `json:"repository,omitempty"`
|
||||
ManagedBy utils.ManagerKind `json:"managedBy,omitempty"`
|
||||
}
|
||||
+6
-6
@@ -94,11 +94,11 @@ func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response {
|
||||
hits := make([]dtos.FolderSearchHit, 0)
|
||||
for _, f := range folders {
|
||||
hits = append(hits, dtos.FolderSearchHit{
|
||||
ID: f.ID, // nolint:staticcheck
|
||||
UID: f.UID,
|
||||
Title: f.Title,
|
||||
ParentUID: f.ParentUID,
|
||||
Repository: f.Repository,
|
||||
ID: f.ID, // nolint:staticcheck
|
||||
UID: f.UID,
|
||||
Title: f.Title,
|
||||
ParentUID: f.ParentUID,
|
||||
ManagedBy: f.ManagedBy,
|
||||
})
|
||||
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolders).Inc()
|
||||
}
|
||||
@@ -427,7 +427,7 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde
|
||||
Version: f.Version,
|
||||
AccessControl: acMetadata,
|
||||
ParentUID: f.ParentUID,
|
||||
Repository: f.Repository,
|
||||
ManagedBy: f.ManagedBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
// ManagerProperties is used to identify the manager of the resource.
|
||||
type ManagerProperties struct {
|
||||
// The kind of manager, which is responsible for managing the resource.
|
||||
// Examples include "git", "terraform", "kubectl", etc.
|
||||
Kind ManagerKind
|
||||
Kind ManagerKind `json:"kind,omitempty"`
|
||||
|
||||
// The identity of the manager, which refers to a specific instance of the manager.
|
||||
// The format & the value depends on the manager kind.
|
||||
Identity string
|
||||
Identity string `json:"id,omitempty"`
|
||||
|
||||
// AllowsEdits indicates whether the manager allows edits to the resource.
|
||||
// If set to true, it means that other requesters can edit the resource.
|
||||
AllowsEdits bool
|
||||
AllowsEdits bool `json:"allowEdits,omitempty"`
|
||||
|
||||
// Suspended indicates whether the manager is suspended.
|
||||
// If set to true, then the manager skip updates to the resource.
|
||||
Suspended bool
|
||||
Suspended bool `json:"suspended,omitempty"`
|
||||
}
|
||||
|
||||
// ManagerKind is the type of manager, which is responsible for managing the resource.
|
||||
// It can be a user or a tool or a generic API client.
|
||||
// +enum
|
||||
type ManagerKind string
|
||||
|
||||
// Known values for ManagerKind.
|
||||
@@ -31,6 +30,11 @@ const (
|
||||
ManagerKindRepo ManagerKind = "repo"
|
||||
ManagerKindTerraform ManagerKind = "terraform"
|
||||
ManagerKindKubectl ManagerKind = "kubectl"
|
||||
ManagerKindPlugin ManagerKind = "plugin"
|
||||
|
||||
// Deprecated: this is used as a shim/migration path for legacy file provisioning
|
||||
// Previously this was a "file:" prefix
|
||||
ManagerKindClassicFP ManagerKind = "classic-file-provisioning"
|
||||
)
|
||||
|
||||
// ParseManagerKindString parses a string into a ManagerKind.
|
||||
@@ -44,6 +48,10 @@ func ParseManagerKindString(v string) ManagerKind {
|
||||
return ManagerKindTerraform
|
||||
case string(ManagerKindKubectl):
|
||||
return ManagerKindKubectl
|
||||
case string(ManagerKindPlugin):
|
||||
return ManagerKindPlugin
|
||||
case string(ManagerKindClassicFP): // nolint:staticcheck
|
||||
return ManagerKindClassicFP // nolint:staticcheck
|
||||
default:
|
||||
return ManagerKindUnknown
|
||||
}
|
||||
@@ -55,13 +63,13 @@ func ParseManagerKindString(v string) ManagerKind {
|
||||
type SourceProperties struct {
|
||||
// The path to the source of the resource.
|
||||
// Can be a file path, a URL, etc.
|
||||
Path string
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// The checksum of the source of the resource.
|
||||
// An example could be a git commit hash.
|
||||
Checksum string
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
|
||||
// The timestamp of the source of the resource.
|
||||
// The unix millis timestamp of the source of the resource.
|
||||
// An example could be the file modification time.
|
||||
Timestamp time.Time
|
||||
TimestampMillis int64 `json:"timestampMillis,omitempty"`
|
||||
}
|
||||
+64
-136
@@ -41,10 +41,10 @@ const AnnoKeyMessage = "grafana.app/message"
|
||||
|
||||
// Identify where values came from
|
||||
|
||||
const AnnoKeyRepoName = "grafana.app/repoName"
|
||||
const AnnoKeyRepoPath = "grafana.app/repoPath"
|
||||
const AnnoKeyRepoHash = "grafana.app/repoHash"
|
||||
const AnnoKeyRepoTimestamp = "grafana.app/repoTimestamp"
|
||||
const oldAnnoKeyRepoName = "grafana.app/repoName"
|
||||
const oldAnnoKeyRepoPath = "grafana.app/repoPath"
|
||||
const oldAnnoKeyRepoHash = "grafana.app/repoHash"
|
||||
const oldAnnoKeyRepoTimestamp = "grafana.app/repoTimestamp"
|
||||
|
||||
// Annotations used to store manager properties
|
||||
|
||||
@@ -56,41 +56,13 @@ const AnnoKeyManagerSuspended = "grafana.app/managerSuspended"
|
||||
// Annotations used to store source properties
|
||||
|
||||
const AnnoKeySourcePath = "grafana.app/sourcePath"
|
||||
const AnnoKeySourceHash = "grafana.app/sourceHash"
|
||||
const AnnoKeySourceChecksum = "grafana.app/sourceChecksum"
|
||||
const AnnoKeySourceTimestamp = "grafana.app/sourceTimestamp"
|
||||
|
||||
// LabelKeyDeprecatedInternalID gives the deprecated internal ID of a resource
|
||||
// Deprecated: will be removed in grafana 13
|
||||
const LabelKeyDeprecatedInternalID = "grafana.app/deprecatedInternalID"
|
||||
|
||||
// These can be removed once we verify that non of the dual-write sources
|
||||
// (for dashboards/playlists/etc) depend on the saved internal ID in SQL
|
||||
const oldAnnoKeyOriginName = "grafana.app/originName"
|
||||
const oldAnnoKeyOriginPath = "grafana.app/originPath"
|
||||
const oldAnnoKeyOriginHash = "grafana.app/originHash"
|
||||
const oldAnnoKeyOriginTimestamp = "grafana.app/originTimestamp"
|
||||
|
||||
// ResourceRepositoryInfo is encoded into kubernetes metadata annotations.
|
||||
// This value identifies indicates the state of the resource in its provisioning source when
|
||||
// the spec was last saved. Currently this is derived from the dashboards provisioning table.
|
||||
type ResourceRepositoryInfo struct {
|
||||
// Name of the repository/provisioning source
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// The path within the named repository above (external_id in the existing dashboard provisioning)
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Verification/identification hash (check_sum in existing dashboard provisioning)
|
||||
Hash string `json:"hash,omitempty"`
|
||||
|
||||
// Origin modification timestamp when the resource was saved
|
||||
// This will be before the resource updated time
|
||||
Timestamp *time.Time `json:"time,omitempty"`
|
||||
|
||||
// Avoid extending
|
||||
_ any `json:"-"`
|
||||
}
|
||||
|
||||
// Accessor functions for k8s objects
|
||||
type GrafanaMetaAccessor interface {
|
||||
metav1.Object
|
||||
@@ -128,13 +100,6 @@ type GrafanaMetaAccessor interface {
|
||||
// Deprecated: This will be removed in Grafana 13
|
||||
SetDeprecatedInternalID(id int64)
|
||||
|
||||
GetRepositoryInfo() (*ResourceRepositoryInfo, error)
|
||||
SetRepositoryInfo(info *ResourceRepositoryInfo)
|
||||
GetRepositoryName() string
|
||||
GetRepositoryPath() string
|
||||
GetRepositoryHash() string
|
||||
GetRepositoryTimestamp() (*time.Time, error)
|
||||
|
||||
GetSpec() (any, error)
|
||||
SetSpec(any) error
|
||||
|
||||
@@ -351,90 +316,6 @@ func (m *grafanaMetaAccessor) SetDeprecatedInternalID(id int64) {
|
||||
m.obj.SetLabels(labels)
|
||||
}
|
||||
|
||||
// This allows looking up a primary and secondary key -- if either exist the value will be returned
|
||||
func (m *grafanaMetaAccessor) getAnnoValue(primary, secondary string) (string, bool) {
|
||||
v, ok := m.obj.GetAnnotations()[primary]
|
||||
if !ok {
|
||||
v, ok = m.obj.GetAnnotations()[secondary]
|
||||
}
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) SetRepositoryInfo(info *ResourceRepositoryInfo) {
|
||||
anno := m.obj.GetAnnotations()
|
||||
if anno == nil {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
anno = make(map[string]string, 0)
|
||||
}
|
||||
|
||||
// remove legacy values
|
||||
delete(anno, oldAnnoKeyOriginHash)
|
||||
delete(anno, oldAnnoKeyOriginPath)
|
||||
delete(anno, oldAnnoKeyOriginHash)
|
||||
delete(anno, oldAnnoKeyOriginTimestamp)
|
||||
|
||||
delete(anno, AnnoKeyRepoName)
|
||||
delete(anno, AnnoKeyRepoPath)
|
||||
delete(anno, AnnoKeyRepoHash)
|
||||
delete(anno, AnnoKeyRepoTimestamp)
|
||||
if info != nil && info.Name != "" {
|
||||
anno[AnnoKeyRepoName] = info.Name
|
||||
if info.Path != "" {
|
||||
anno[AnnoKeyRepoPath] = info.Path
|
||||
}
|
||||
if info.Hash != "" {
|
||||
anno[AnnoKeyRepoHash] = info.Hash
|
||||
}
|
||||
if info.Timestamp != nil {
|
||||
anno[AnnoKeyRepoTimestamp] = info.Timestamp.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
m.obj.SetAnnotations(anno)
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetRepositoryInfo() (*ResourceRepositoryInfo, error) {
|
||||
v, ok := m.getAnnoValue(AnnoKeyRepoName, oldAnnoKeyOriginName)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := m.GetRepositoryTimestamp()
|
||||
return &ResourceRepositoryInfo{
|
||||
Name: v,
|
||||
Path: m.GetRepositoryPath(),
|
||||
Hash: m.GetRepositoryHash(),
|
||||
Timestamp: t,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetRepositoryName() string {
|
||||
v, _ := m.getAnnoValue(AnnoKeyRepoName, oldAnnoKeyOriginName)
|
||||
return v // will be empty string
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetRepositoryPath() string {
|
||||
v, _ := m.getAnnoValue(AnnoKeyRepoPath, oldAnnoKeyOriginPath)
|
||||
return v // will be empty string
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetRepositoryHash() string {
|
||||
v, _ := m.getAnnoValue(AnnoKeyRepoHash, oldAnnoKeyOriginHash)
|
||||
return v // will be empty string
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetRepositoryTimestamp() (*time.Time, error) {
|
||||
v, ok := m.getAnnoValue(AnnoKeyRepoTimestamp, oldAnnoKeyOriginTimestamp)
|
||||
if !ok || v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid origin timestamp: %s", err.Error())
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// GetAnnotations implements GrafanaMetaAccessor.
|
||||
func (m *grafanaMetaAccessor) GetAnnotations() map[string]string {
|
||||
return m.obj.GetAnnotations()
|
||||
@@ -751,7 +632,7 @@ func (m *grafanaMetaAccessor) GetManagerProperties() (ManagerProperties, bool) {
|
||||
res := ManagerProperties{
|
||||
Identity: "",
|
||||
Kind: ManagerKindUnknown,
|
||||
AllowsEdits: true,
|
||||
AllowsEdits: false,
|
||||
Suspended: false,
|
||||
}
|
||||
|
||||
@@ -759,6 +640,15 @@ func (m *grafanaMetaAccessor) GetManagerProperties() (ManagerProperties, bool) {
|
||||
|
||||
id, ok := annot[AnnoKeyManagerIdentity]
|
||||
if !ok || id == "" {
|
||||
// Temporarily support the repo name annotation
|
||||
repo := annot[oldAnnoKeyRepoName]
|
||||
if repo != "" {
|
||||
return ManagerProperties{
|
||||
Kind: ManagerKindRepo,
|
||||
Identity: repo,
|
||||
}, true
|
||||
}
|
||||
|
||||
// If the identity is not set, we should ignore the other annotations and return the default values.
|
||||
//
|
||||
// This is to prevent inadvertently marking resources as managed,
|
||||
@@ -788,10 +678,31 @@ func (m *grafanaMetaAccessor) SetManagerProperties(v ManagerProperties) {
|
||||
annot = make(map[string]string, 4)
|
||||
}
|
||||
|
||||
annot[AnnoKeyManagerIdentity] = v.Identity
|
||||
annot[AnnoKeyManagerKind] = string(v.Kind)
|
||||
annot[AnnoKeyManagerAllowsEdits] = strconv.FormatBool(v.AllowsEdits)
|
||||
annot[AnnoKeyManagerSuspended] = strconv.FormatBool(v.Suspended)
|
||||
if v.Identity != "" {
|
||||
annot[AnnoKeyManagerIdentity] = v.Identity
|
||||
} else {
|
||||
delete(annot, AnnoKeyManagerIdentity)
|
||||
}
|
||||
|
||||
if string(v.Kind) != "" {
|
||||
annot[AnnoKeyManagerKind] = string(v.Kind)
|
||||
} else {
|
||||
delete(annot, AnnoKeyManagerKind)
|
||||
}
|
||||
|
||||
if v.AllowsEdits {
|
||||
annot[AnnoKeyManagerAllowsEdits] = strconv.FormatBool(v.AllowsEdits)
|
||||
} else {
|
||||
delete(annot, AnnoKeyManagerAllowsEdits)
|
||||
}
|
||||
if v.Suspended {
|
||||
annot[AnnoKeyManagerSuspended] = strconv.FormatBool(v.Suspended)
|
||||
} else {
|
||||
delete(annot, AnnoKeyManagerSuspended)
|
||||
}
|
||||
|
||||
// Clean up old annotation access
|
||||
delete(annot, oldAnnoKeyRepoName)
|
||||
|
||||
m.obj.SetAnnotations(annot)
|
||||
}
|
||||
@@ -810,16 +721,27 @@ func (m *grafanaMetaAccessor) GetSourceProperties() (SourceProperties, bool) {
|
||||
if path, ok := annot[AnnoKeySourcePath]; ok && path != "" {
|
||||
res.Path = path
|
||||
found = true
|
||||
} else if path, ok := annot[oldAnnoKeyRepoPath]; ok && path != "" {
|
||||
res.Path = path
|
||||
found = true
|
||||
}
|
||||
|
||||
if hash, ok := annot[AnnoKeySourceHash]; ok && hash != "" {
|
||||
if hash, ok := annot[AnnoKeySourceChecksum]; ok && hash != "" {
|
||||
res.Checksum = hash
|
||||
found = true
|
||||
} else if hash, ok := annot[oldAnnoKeyRepoHash]; ok && hash != "" {
|
||||
res.Checksum = hash
|
||||
found = true
|
||||
}
|
||||
|
||||
if timestamp, ok := annot[AnnoKeySourceTimestamp]; ok && timestamp != "" {
|
||||
if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
|
||||
res.Timestamp = t
|
||||
t, ok := annot[AnnoKeySourceTimestamp]
|
||||
if !ok {
|
||||
t, ok = annot[oldAnnoKeyRepoTimestamp]
|
||||
}
|
||||
if ok && t != "" {
|
||||
var err error
|
||||
res.TimestampMillis, err = strconv.ParseInt(t, 10, 64)
|
||||
if err != nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
@@ -835,14 +757,20 @@ func (m *grafanaMetaAccessor) SetSourceProperties(v SourceProperties) {
|
||||
|
||||
if v.Path != "" {
|
||||
annot[AnnoKeySourcePath] = v.Path
|
||||
} else {
|
||||
delete(annot, AnnoKeySourcePath)
|
||||
}
|
||||
|
||||
if v.Checksum != "" {
|
||||
annot[AnnoKeySourceHash] = v.Checksum
|
||||
annot[AnnoKeySourceChecksum] = v.Checksum
|
||||
} else {
|
||||
delete(annot, AnnoKeySourceChecksum)
|
||||
}
|
||||
|
||||
if !v.Timestamp.IsZero() {
|
||||
annot[AnnoKeySourceTimestamp] = v.Timestamp.Format(time.RFC3339)
|
||||
if v.TimestampMillis > 0 {
|
||||
annot[AnnoKeySourceTimestamp] = strconv.FormatInt(v.TimestampMillis, 10)
|
||||
} else {
|
||||
delete(annot, AnnoKeySourceTimestamp)
|
||||
}
|
||||
|
||||
m.obj.SetAnnotations(annot)
|
||||
|
||||
@@ -131,10 +131,13 @@ func (in *Spec2) DeepCopy() *Spec2 {
|
||||
}
|
||||
|
||||
func TestMetaAccessor(t *testing.T) {
|
||||
repoInfo := &utils.ResourceRepositoryInfo{
|
||||
Name: "test",
|
||||
Path: "a/b/c",
|
||||
Hash: "kkk",
|
||||
repoInfo := utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "test",
|
||||
}
|
||||
sourceInfo := utils.SourceProperties{
|
||||
Path: "a/b/c",
|
||||
Checksum: "kkk",
|
||||
}
|
||||
|
||||
t.Run("fails for non resource objects", func(t *testing.T) {
|
||||
@@ -202,14 +205,13 @@ func TestMetaAccessor(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
meta.SetRepositoryInfo(repoInfo)
|
||||
meta.SetManagerProperties(repoInfo)
|
||||
meta.SetFolder("folderUID")
|
||||
|
||||
require.Equal(t, map[string]string{
|
||||
"grafana.app/repoName": "test",
|
||||
"grafana.app/repoPath": "a/b/c",
|
||||
"grafana.app/repoHash": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
"grafana.app/managedBy": "repo",
|
||||
"grafana.app/managerId": "test",
|
||||
"grafana.app/folder": "folderUID",
|
||||
}, res.GetAnnotations())
|
||||
|
||||
meta.SetNamespace("aaa")
|
||||
@@ -255,14 +257,16 @@ func TestMetaAccessor(t *testing.T) {
|
||||
meta, err := utils.MetaAccessor(res)
|
||||
require.NoError(t, err)
|
||||
|
||||
meta.SetRepositoryInfo(repoInfo)
|
||||
meta.SetManagerProperties(repoInfo)
|
||||
meta.SetSourceProperties(sourceInfo)
|
||||
meta.SetFolder("folderUID")
|
||||
|
||||
require.Equal(t, map[string]string{
|
||||
"grafana.app/repoName": "test",
|
||||
"grafana.app/repoPath": "a/b/c",
|
||||
"grafana.app/repoHash": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
"grafana.app/managedBy": "repo",
|
||||
"grafana.app/managerId": "test",
|
||||
"grafana.app/sourcePath": "a/b/c",
|
||||
"grafana.app/sourceChecksum": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
}, res.GetAnnotations())
|
||||
|
||||
meta.SetNamespace("aaa")
|
||||
@@ -306,14 +310,13 @@ func TestMetaAccessor(t *testing.T) {
|
||||
meta, err := utils.MetaAccessor(res)
|
||||
require.NoError(t, err)
|
||||
|
||||
meta.SetRepositoryInfo(repoInfo)
|
||||
meta.SetManagerProperties(repoInfo)
|
||||
meta.SetFolder("folderUID")
|
||||
|
||||
require.Equal(t, map[string]string{
|
||||
"grafana.app/repoName": "test",
|
||||
"grafana.app/repoPath": "a/b/c",
|
||||
"grafana.app/repoHash": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
"grafana.app/managedBy": "repo",
|
||||
"grafana.app/managerId": "test",
|
||||
"grafana.app/folder": "folderUID",
|
||||
}, res.GetAnnotations())
|
||||
|
||||
meta.SetNamespace("aaa")
|
||||
@@ -347,7 +350,7 @@ func TestMetaAccessor(t *testing.T) {
|
||||
require.Equal(t, "ZZ", res.Status.Title)
|
||||
})
|
||||
|
||||
t.Run("test reading old originInfo (now repository)", func(t *testing.T) {
|
||||
t.Run("test reading old repo fields (now manager+source)", func(t *testing.T) {
|
||||
res := &TestResource2{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
@@ -362,11 +365,15 @@ func TestMetaAccessor(t *testing.T) {
|
||||
meta, err := utils.MetaAccessor(res)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := meta.GetRepositoryInfo()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", info.Name)
|
||||
require.Equal(t, "a/b/c", info.Path)
|
||||
require.Equal(t, "zzz", info.Hash)
|
||||
manager, ok := meta.GetManagerProperties()
|
||||
require.True(t, ok)
|
||||
require.Equal(t, utils.ManagerKindRepo, manager.Kind)
|
||||
require.Equal(t, "test", manager.Identity)
|
||||
|
||||
source, ok := meta.GetSourceProperties()
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "a/b/c", source.Path)
|
||||
require.Equal(t, "zzz", source.Checksum)
|
||||
})
|
||||
|
||||
t.Run("blob info", func(t *testing.T) {
|
||||
@@ -391,14 +398,16 @@ func TestMetaAccessor(t *testing.T) {
|
||||
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
require.NoError(t, err)
|
||||
meta.SetRepositoryInfo(repoInfo)
|
||||
meta.SetManagerProperties(repoInfo)
|
||||
meta.SetSourceProperties(sourceInfo)
|
||||
meta.SetFolder("folderUID")
|
||||
|
||||
require.Equal(t, map[string]string{
|
||||
"grafana.app/repoName": "test",
|
||||
"grafana.app/repoPath": "a/b/c",
|
||||
"grafana.app/repoHash": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
"grafana.app/managedBy": "repo",
|
||||
"grafana.app/managerId": "test",
|
||||
"grafana.app/sourcePath": "a/b/c",
|
||||
"grafana.app/sourceChecksum": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
}, obj.GetAnnotations())
|
||||
|
||||
require.Equal(t, "HELLO", obj.Spec.Title)
|
||||
@@ -414,14 +423,13 @@ func TestMetaAccessor(t *testing.T) {
|
||||
|
||||
meta, err = utils.MetaAccessor(obj2)
|
||||
require.NoError(t, err)
|
||||
meta.SetRepositoryInfo(repoInfo)
|
||||
meta.SetManagerProperties(repoInfo)
|
||||
meta.SetFolder("folderUID")
|
||||
|
||||
require.Equal(t, map[string]string{
|
||||
"grafana.app/repoName": "test",
|
||||
"grafana.app/repoPath": "a/b/c",
|
||||
"grafana.app/repoHash": "kkk",
|
||||
"grafana.app/folder": "folderUID",
|
||||
"grafana.app/managedBy": "repo",
|
||||
"grafana.app/managerId": "test",
|
||||
"grafana.app/folder": "folderUID",
|
||||
}, obj2.GetAnnotations())
|
||||
|
||||
require.Equal(t, "xxx", meta.FindTitle("xxx"))
|
||||
@@ -447,7 +455,7 @@ func TestMetaAccessor(t *testing.T) {
|
||||
wantProperties: utils.ManagerProperties{
|
||||
Identity: "",
|
||||
Kind: utils.ManagerKindUnknown,
|
||||
AllowsEdits: true,
|
||||
AllowsEdits: false,
|
||||
Suspended: false,
|
||||
},
|
||||
wantOK: false,
|
||||
@@ -479,7 +487,7 @@ func TestMetaAccessor(t *testing.T) {
|
||||
wantProperties: utils.ManagerProperties{
|
||||
Identity: "",
|
||||
Kind: utils.ManagerKindUnknown,
|
||||
AllowsEdits: true,
|
||||
AllowsEdits: false,
|
||||
Suspended: false,
|
||||
},
|
||||
wantOK: false,
|
||||
@@ -534,14 +542,14 @@ func TestMetaAccessor(t *testing.T) {
|
||||
{
|
||||
name: "set and get valid values",
|
||||
setProperties: &utils.SourceProperties{
|
||||
Path: "path",
|
||||
Checksum: "hash",
|
||||
Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Path: "path",
|
||||
Checksum: "hash",
|
||||
TimestampMillis: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
},
|
||||
wantProperties: utils.SourceProperties{
|
||||
Path: "path",
|
||||
Checksum: "hash",
|
||||
Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Path: "path",
|
||||
Checksum: "hash",
|
||||
TimestampMillis: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
},
|
||||
wantOK: true,
|
||||
},
|
||||
|
||||
+11
-31
@@ -1,51 +1,31 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
var PluginIDRepoName = "plugin"
|
||||
var fileProvisionedRepoPrefix = "file:"
|
||||
|
||||
// ProvisionedFileNameWithPrefix adds the `file:` prefix to the
|
||||
// provisioner name, to be used as the annotation for dashboards
|
||||
// provisioned from files
|
||||
func ProvisionedFileNameWithPrefix(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fileProvisionedRepoPrefix + name
|
||||
}
|
||||
|
||||
// GetProvisionedFileNameFromMeta returns the provisioner name
|
||||
// from a given annotation string, which is in the form file:<name>
|
||||
func GetProvisionedFileNameFromMeta(annotation string) (string, bool) {
|
||||
return strings.CutPrefix(annotation, fileProvisionedRepoPrefix)
|
||||
}
|
||||
|
||||
// SetPluginIDMeta sets the repo name to "plugin" and the path to the plugin ID
|
||||
func SetPluginIDMeta(obj unstructured.Unstructured, pluginID string) {
|
||||
func SetPluginIDMeta(obj *unstructured.Unstructured, pluginID string) {
|
||||
if pluginID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
annotations := obj.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err == nil {
|
||||
meta.SetManagerProperties(utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindPlugin,
|
||||
Identity: pluginID,
|
||||
})
|
||||
}
|
||||
annotations[utils.AnnoKeyRepoName] = PluginIDRepoName
|
||||
annotations[utils.AnnoKeyRepoPath] = pluginID
|
||||
obj.SetAnnotations(annotations)
|
||||
}
|
||||
|
||||
// GetPluginIDFromMeta returns the plugin ID from the meta if the repo name is "plugin"
|
||||
func GetPluginIDFromMeta(obj utils.GrafanaMetaAccessor) string {
|
||||
if obj.GetRepositoryName() == PluginIDRepoName {
|
||||
return obj.GetRepositoryPath()
|
||||
p, ok := obj.GetManagerProperties()
|
||||
if ok && p.Kind == utils.ManagerKindPlugin {
|
||||
return p.Identity
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -317,13 +317,6 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
|
||||
}
|
||||
|
||||
if origin_name.String != "" {
|
||||
ts := time.Unix(origin_ts.Int64, 0)
|
||||
|
||||
repo := &utils.ResourceRepositoryInfo{
|
||||
Name: dashboardOG.ProvisionedFileNameWithPrefix(origin_name.String),
|
||||
Hash: origin_hash.String,
|
||||
Timestamp: &ts,
|
||||
}
|
||||
// if the reader cannot be found, it may be an orphaned provisioned dashboard
|
||||
resolvedPath := a.provisioning.GetDashboardProvisionerResolvedPath(origin_name.String)
|
||||
if resolvedPath != "" {
|
||||
@@ -334,13 +327,20 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo.Path = originPath
|
||||
meta.SetSourceProperties(utils.SourceProperties{
|
||||
Path: originPath, // relative path within source
|
||||
Checksum: origin_hash.String,
|
||||
TimestampMillis: origin_ts.Int64,
|
||||
})
|
||||
meta.SetManagerProperties(utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindClassicFP, // nolint:staticcheck
|
||||
Identity: origin_name.String,
|
||||
})
|
||||
}
|
||||
meta.SetRepositoryInfo(repo)
|
||||
} else if plugin_id.String != "" {
|
||||
meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{
|
||||
Name: dashboardOG.PluginIDRepoName,
|
||||
Path: plugin_id.String,
|
||||
meta.SetManagerProperties(utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindPlugin,
|
||||
Identity: plugin_id.String,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,12 +83,18 @@ func TestScanRow(t *testing.T) {
|
||||
|
||||
meta, err := utils.MetaAccessor(row.Dash)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file:provisioner", meta.GetRepositoryName()) // should be prefixed by file:
|
||||
require.Equal(t, "../"+pathToFile, meta.GetRepositoryPath()) // relative to provisioner
|
||||
require.Equal(t, "hashing", meta.GetRepositoryHash())
|
||||
ts, err := meta.GetRepositoryTimestamp()
|
||||
m, ok := meta.GetManagerProperties()
|
||||
require.True(t, ok)
|
||||
|
||||
s, ok := meta.GetSourceProperties()
|
||||
require.True(t, ok)
|
||||
|
||||
require.Equal(t, utils.ManagerKindClassicFP, m.Kind) // nolint:staticcheck
|
||||
require.Equal(t, "provisioner", m.Identity)
|
||||
require.Equal(t, "../"+pathToFile, s.Path) // relative to provisioner
|
||||
require.Equal(t, "hashing", s.Checksum)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(100000), ts.Unix())
|
||||
require.Equal(t, int64(100000), s.TimestampMillis)
|
||||
})
|
||||
|
||||
t.Run("Plugin provisioned dashboard should have annotations", func(t *testing.T) {
|
||||
@@ -105,8 +111,11 @@ func TestScanRow(t *testing.T) {
|
||||
|
||||
meta, err := utils.MetaAccessor(row.Dash)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "plugin", meta.GetRepositoryName())
|
||||
require.Equal(t, "slo", meta.GetRepositoryPath()) // the ID of the plugin
|
||||
require.Equal(t, "", meta.GetRepositoryHash()) // hash is not used on plugins
|
||||
manager, ok := meta.GetManagerProperties()
|
||||
require.True(t, ok)
|
||||
|
||||
require.Equal(t, utils.ManagerKindPlugin, manager.Kind)
|
||||
require.Equal(t, "slo", manager.Identity) // the ID of the plugin
|
||||
require.Equal(t, "", meta.GetAnnotations()[utils.AnnoKeySourceChecksum]) // hash is not used on plugins
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
dashboardOG "github.com/grafana/grafana/pkg/apis/dashboard"
|
||||
dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
|
||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -181,18 +180,22 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
||||
}
|
||||
|
||||
query.FolderUIDs = folders
|
||||
case resource.SEARCH_FIELD_REPOSITORY_PATH:
|
||||
case resource.SEARCH_FIELD_SOURCE_PATH:
|
||||
// only one value is supported in legacy search
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one repo path query is supported")
|
||||
}
|
||||
query.ProvisionedPath = vals[0]
|
||||
case resource.SEARCH_FIELD_REPOSITORY_NAME:
|
||||
query.SourcePath = vals[0]
|
||||
|
||||
case resource.SEARCH_FIELD_MANAGER_KIND:
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one manager kind supported")
|
||||
}
|
||||
query.ManagedBy = utils.ManagerKind(vals[0])
|
||||
|
||||
case resource.SEARCH_FIELD_MANAGER_ID:
|
||||
if field.Operator == string(selection.NotIn) {
|
||||
for _, val := range vals {
|
||||
name, _ := dashboardOG.GetProvisionedFileNameFromMeta(val)
|
||||
query.ProvisionedReposNotIn = append(query.ProvisionedReposNotIn, name)
|
||||
}
|
||||
query.ManagerIdentityNotIn = vals
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -200,8 +203,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
||||
if len(vals) != 1 {
|
||||
return nil, fmt.Errorf("only one repo name is supported")
|
||||
}
|
||||
|
||||
query.ProvisionedRepo, _ = dashboardOG.GetProvisionedFileNameFromMeta(vals[0])
|
||||
query.ManagerIdentity = vals[0]
|
||||
}
|
||||
}
|
||||
searchFields := resource.StandardSearchFields()
|
||||
@@ -221,17 +223,21 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
||||
|
||||
// if we are querying for provisioning information, we need to use a different
|
||||
// legacy sql query, since legacy search does not support this
|
||||
if query.ProvisionedRepo != "" || len(query.ProvisionedReposNotIn) > 0 {
|
||||
if query.ManagerIdentity != "" || len(query.ManagerIdentityNotIn) > 0 {
|
||||
if query.ManagedBy == utils.ManagerKindUnknown {
|
||||
return nil, fmt.Errorf("query by manager identity also requires manager.kind parameter")
|
||||
}
|
||||
|
||||
var dashes []*dashboards.Dashboard
|
||||
if query.ProvisionedRepo == dashboardOG.PluginIDRepoName {
|
||||
if query.ManagedBy == utils.ManagerKindPlugin {
|
||||
dashes, err = c.dashboardStore.GetDashboardsByPluginID(ctx, &dashboards.GetDashboardsByPluginIDQuery{
|
||||
PluginID: query.ProvisionedPath,
|
||||
PluginID: query.ManagerIdentity,
|
||||
OrgID: user.GetOrgID(),
|
||||
})
|
||||
} else if query.ProvisionedRepo != "" {
|
||||
dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ProvisionedRepo)
|
||||
} else if len(query.ProvisionedReposNotIn) > 0 {
|
||||
dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ProvisionedReposNotIn)
|
||||
} else if query.ManagerIdentity != "" {
|
||||
dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ManagerIdentity)
|
||||
} else if len(query.ManagerIdentityNotIn) > 0 {
|
||||
dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ManagerIdentityNotIn)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -373,12 +373,12 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
Key: dashboardKey,
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_PATH,
|
||||
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
Operator: "in",
|
||||
Values: []string{"slo"},
|
||||
},
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
Key: resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
Operator: "in",
|
||||
Values: []string{"plugin"},
|
||||
},
|
||||
@@ -402,9 +402,14 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
Key: dashboardKey,
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
Key: resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
Operator: "=",
|
||||
Values: []string{string(utils.ManagerKindClassicFP)}, // nolint:staticcheck
|
||||
},
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
Operator: "in",
|
||||
Values: []string{"file:test"}, // file prefix should be removed before going to legacy
|
||||
Values: []string{"test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -426,9 +431,14 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
Key: dashboardKey,
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
Key: resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
Operator: "=",
|
||||
Values: []string{string(utils.ManagerKindClassicFP)}, // nolint:staticcheck
|
||||
},
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
Operator: string(selection.NotIn),
|
||||
Values: []string{"file:test", "file:test2"}, // file prefix should be removed before going to legacy
|
||||
Values: []string{"test", "test2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -134,13 +134,9 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
|
||||
OrgID: info.OrgID,
|
||||
ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
|
||||
}
|
||||
repo, err := obj.GetRepositoryInfo()
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
if repo != nil && repo.Name == dashboard.PluginIDRepoName {
|
||||
dto.PluginID = repo.Path
|
||||
manager, ok := obj.GetManagerProperties()
|
||||
if ok && manager.Kind == utils.ManagerKindPlugin {
|
||||
dto.PluginID = manager.Identity
|
||||
}
|
||||
|
||||
guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user)
|
||||
|
||||
@@ -2,7 +2,6 @@ package folders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -138,13 +137,14 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge
|
||||
UID: &name,
|
||||
OrgID: info.OrgID,
|
||||
})
|
||||
if err != nil || dto == nil {
|
||||
if errors.Is(err, dashboards.ErrFolderNotFound) || err == nil {
|
||||
err = resourceInfo.NewNotFound(name)
|
||||
}
|
||||
if err != nil {
|
||||
statusErr := apierrors.ToFolderStatusError(err)
|
||||
return nil, &statusErr
|
||||
}
|
||||
if dto == nil {
|
||||
statusErr := apierrors.ToFolderStatusError(dashboards.ErrFolderNotFound)
|
||||
return nil, &statusErr
|
||||
}
|
||||
|
||||
r, err := convertToK8sResource(dto, s.namespacer)
|
||||
if err != nil {
|
||||
|
||||
@@ -297,6 +297,10 @@ func (b *FolderAPIBuilder) validateOnCreate(ctx context.Context, id string, obj
|
||||
return dashboards.ErrFolderTitleEmpty
|
||||
}
|
||||
|
||||
if f.Name == getParent(obj) {
|
||||
return folder.ErrFolderCannotBeParentOfItself
|
||||
}
|
||||
|
||||
_, err := b.checkFolderMaxDepth(ctx, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -26,13 +27,22 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||
name string
|
||||
}
|
||||
|
||||
circularObj := &v0alpha1.Folder{
|
||||
initialMaxDepth := folderValidationRules.maxDepth
|
||||
folderValidationRules.maxDepth = 2
|
||||
defer func() { folderValidationRules.maxDepth = initialMaxDepth }()
|
||||
deepFolder := &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo",
|
||||
},
|
||||
}
|
||||
circularObj.Name = "valid-name"
|
||||
circularObj.Annotations = map[string]string{"grafana.app/folder": "valid-name"}
|
||||
deepFolder.Name = "valid-parent"
|
||||
deepFolder.Annotations = map[string]string{"grafana.app/folder": "valid-grandparent"}
|
||||
parentFolder := &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo-grandparent",
|
||||
},
|
||||
}
|
||||
deepFolder.Name = "valid-grandparent"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -71,12 +81,15 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||
Title: "foo",
|
||||
},
|
||||
},
|
||||
annotations: map[string]string{"grafana.app/folder": "valid-name"},
|
||||
annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||
name: "valid-name",
|
||||
},
|
||||
setupFn: func(m *mock.Mock) {
|
||||
m.On("Get", mock.Anything, "valid-name", mock.Anything).Return(
|
||||
circularObj,
|
||||
m.On("Get", mock.Anything, "valid-parent", mock.Anything).Return(
|
||||
deepFolder,
|
||||
nil)
|
||||
m.On("Get", mock.Anything, "valid-grandparent", mock.Anything).Return(
|
||||
parentFolder,
|
||||
nil)
|
||||
},
|
||||
err: folder.ErrMaximumDepthReached,
|
||||
@@ -93,6 +106,19 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||
},
|
||||
err: dashboards.ErrFolderTitleEmpty,
|
||||
},
|
||||
{
|
||||
name: "should return error if folder is a parent of itself",
|
||||
input: input{
|
||||
annotations: map[string]string{utils.AnnoKeyFolder: "myself"},
|
||||
obj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "title",
|
||||
},
|
||||
},
|
||||
name: "myself",
|
||||
},
|
||||
err: folder.ErrFolderCannotBeParentOfItself,
|
||||
},
|
||||
}
|
||||
|
||||
s := (grafanarest.Storage)(nil)
|
||||
|
||||
@@ -80,6 +80,8 @@ var (
|
||||
ready = make(chan struct{})
|
||||
)
|
||||
|
||||
const MaxRequestBodyBytes = 16 * 1024 * 1024 // 16MB - determined by the size of `mediumtext` on mysql, which is used to save dashboard data
|
||||
|
||||
func init() {
|
||||
// we need to add the options to empty v1
|
||||
metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Group: "", Version: "v1"})
|
||||
@@ -335,6 +337,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
transport := &roundTripperFunc{ready: make(chan struct{})}
|
||||
serverConfig.LoopbackClientConfig.Transport = transport
|
||||
serverConfig.LoopbackClientConfig.TLSClientConfig = clientrest.TLSClientConfig{}
|
||||
serverConfig.MaxRequestBodyBytes = MaxRequestBodyBytes
|
||||
|
||||
var optsregister apistore.StorageOptionsRegister
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ var (
|
||||
StatusCode: 400,
|
||||
Status: "empty-name",
|
||||
}
|
||||
ErrDashboardTitleTooLong = DashboardErr{
|
||||
Reason: "Dashboard title cannot contain more than 5 000 characters",
|
||||
StatusCode: 400,
|
||||
Status: "title-too-long",
|
||||
}
|
||||
ErrDashboardFolderCannotHaveParent = DashboardErr{
|
||||
Reason: "A Dashboard Folder cannot be added to another folder",
|
||||
StatusCode: 400,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
@@ -438,9 +439,10 @@ type FindPersistedDashboardsQuery struct {
|
||||
Sort model.SortOption
|
||||
IsDeleted bool
|
||||
|
||||
ProvisionedRepo string
|
||||
ProvisionedPath string
|
||||
ProvisionedReposNotIn []string
|
||||
ManagedBy utils.ManagerKind
|
||||
ManagerIdentity string
|
||||
SourcePath string
|
||||
ManagerIdentityNotIn []string
|
||||
|
||||
Filters []any
|
||||
|
||||
|
||||
@@ -237,7 +237,8 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context,
|
||||
func(orgID int64) {
|
||||
g.Go(func() error {
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ProvisionedRepo: name,
|
||||
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
||||
ManagerIdentity: name,
|
||||
OrgId: orgID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -341,6 +342,10 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
|
||||
return nil, dashboards.ErrDashboardTitleEmpty
|
||||
}
|
||||
|
||||
if len(dash.Title) > 5000 {
|
||||
return nil, dashboards.ErrDashboardTitleTooLong
|
||||
}
|
||||
|
||||
if len(dto.Message) > 500 {
|
||||
return nil, dashboards.ErrDashboardMessageTooLong
|
||||
}
|
||||
@@ -564,8 +569,8 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
||||
// find all dashboards in the org that have a file repo set that is not in the given readers list
|
||||
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ProvisionedReposNotIn: cmd.ReaderNames,
|
||||
OrgId: org.ID,
|
||||
ManagerIdentityNotIn: cmd.ReaderNames,
|
||||
OrgId: org.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -958,8 +963,8 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
|
||||
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: query.OrgID,
|
||||
ProvisionedRepo: dashboard.PluginIDRepoName,
|
||||
ProvisionedPath: query.PluginID,
|
||||
ManagedBy: utils.ManagerKindPlugin,
|
||||
ManagerIdentity: query.PluginID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1549,22 +1554,22 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annotations := obj.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if unprovision {
|
||||
delete(annotations, utils.AnnoKeyRepoName)
|
||||
delete(annotations, utils.AnnoKeyRepoPath)
|
||||
delete(annotations, utils.AnnoKeyRepoHash)
|
||||
delete(annotations, utils.AnnoKeyRepoTimestamp)
|
||||
} else {
|
||||
annotations[utils.AnnoKeyRepoName] = dashboard.ProvisionedFileNameWithPrefix(provisioning.Name)
|
||||
annotations[utils.AnnoKeyRepoPath] = provisioning.ExternalID
|
||||
annotations[utils.AnnoKeyRepoHash] = provisioning.CheckSum
|
||||
annotations[utils.AnnoKeyRepoTimestamp] = time.Unix(provisioning.Updated, 0).UTC().Format(time.RFC3339)
|
||||
|
||||
m := utils.ManagerProperties{}
|
||||
s := utils.SourceProperties{}
|
||||
if !unprovision {
|
||||
m.Kind = utils.ManagerKindClassicFP // nolint:staticcheck
|
||||
m.Identity = provisioning.Name
|
||||
s.Path = provisioning.ExternalID
|
||||
s.Checksum = provisioning.CheckSum
|
||||
s.TimestampMillis = time.Unix(provisioning.Updated, 0).UnixMilli()
|
||||
}
|
||||
obj.SetAnnotations(annotations)
|
||||
meta.SetManagerProperties(m)
|
||||
meta.SetSourceProperties(s)
|
||||
|
||||
out, err := dr.createOrUpdateDash(ctx, obj, cmd.OrgID)
|
||||
if err != nil {
|
||||
@@ -1590,16 +1595,16 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
|
||||
func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
|
||||
var out *unstructured.Unstructured
|
||||
current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID, v1.GetOptions{})
|
||||
if current == nil || err != nil {
|
||||
out, err = dr.k8sclient.Create(ctx, &obj, orgID)
|
||||
out, err = dr.k8sclient.Create(ctx, obj, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
out, err = dr.k8sclient.Update(ctx, &obj, orgID)
|
||||
out, err = dr.k8sclient.Update(ctx, obj, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1719,30 +1724,35 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
|
||||
})
|
||||
}
|
||||
|
||||
if query.ProvisionedRepo != "" {
|
||||
req := []*resource.Requirement{{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{query.ProvisionedRepo},
|
||||
}}
|
||||
request.Options.Fields = append(request.Options.Fields, req...)
|
||||
if query.ManagedBy != "" {
|
||||
request.Options.Fields = append(request.Options.Fields, &resource.Requirement{
|
||||
Key: resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{string(query.ManagedBy)},
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.ProvisionedReposNotIn) > 0 {
|
||||
req := []*resource.Requirement{{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
Operator: string(selection.NotIn),
|
||||
Values: query.ProvisionedReposNotIn,
|
||||
}}
|
||||
request.Options.Fields = append(request.Options.Fields, req...)
|
||||
}
|
||||
if query.ProvisionedPath != "" {
|
||||
req := []*resource.Requirement{{
|
||||
Key: resource.SEARCH_FIELD_REPOSITORY_PATH,
|
||||
if query.ManagerIdentity != "" {
|
||||
request.Options.Fields = append(request.Options.Fields, &resource.Requirement{
|
||||
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{query.ProvisionedPath},
|
||||
}}
|
||||
request.Options.Fields = append(request.Options.Fields, req...)
|
||||
Values: []string{query.ManagerIdentity},
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.ManagerIdentityNotIn) > 0 {
|
||||
request.Options.Fields = append(request.Options.Fields, &resource.Requirement{
|
||||
Key: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
Operator: string(selection.NotIn),
|
||||
Values: query.ManagerIdentityNotIn,
|
||||
})
|
||||
}
|
||||
if query.SourcePath != "" {
|
||||
request.Options.Fields = append(request.Options.Fields, &resource.Requirement{
|
||||
Key: resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{query.SourcePath},
|
||||
})
|
||||
}
|
||||
|
||||
if query.Title != "" {
|
||||
@@ -1836,18 +1846,6 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
|
||||
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId)
|
||||
|
||||
if query.ProvisionedRepo != "" {
|
||||
query.ProvisionedRepo = dashboard.ProvisionedFileNameWithPrefix(query.ProvisionedRepo)
|
||||
}
|
||||
|
||||
if len(query.ProvisionedReposNotIn) > 0 {
|
||||
repos := make([]string, len(query.ProvisionedReposNotIn))
|
||||
for i, v := range query.ProvisionedReposNotIn {
|
||||
repos[i] = dashboard.ProvisionedFileNameWithPrefix(v)
|
||||
}
|
||||
query.ProvisionedReposNotIn = repos
|
||||
}
|
||||
|
||||
query.Type = searchstore.TypeDashboard
|
||||
|
||||
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
||||
@@ -1874,26 +1872,27 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure the repo is set due to file provisioning, otherwise skip it
|
||||
fileRepo, found := dashboard.GetProvisionedFileNameFromMeta(meta.GetRepositoryName())
|
||||
if !found {
|
||||
m, ok := meta.GetManagerProperties()
|
||||
if !ok || m.Kind != utils.ManagerKindClassicFP { // nolint:staticcheck
|
||||
return nil
|
||||
}
|
||||
|
||||
source, ok := meta.GetSourceProperties()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
provisioning := &dashboardProvisioningWithUID{
|
||||
DashboardProvisioning: dashboards.DashboardProvisioning{
|
||||
Name: m.Identity,
|
||||
ExternalID: source.Path,
|
||||
CheckSum: source.Checksum,
|
||||
DashboardID: meta.GetDeprecatedInternalID(), // nolint:staticcheck
|
||||
},
|
||||
DashboardUID: hit.Name,
|
||||
}
|
||||
provisioning.Name = fileRepo
|
||||
provisioning.ExternalID = meta.GetRepositoryPath()
|
||||
provisioning.CheckSum = meta.GetRepositoryHash()
|
||||
provisioning.DashboardID = meta.GetDeprecatedInternalID() // nolint:staticcheck
|
||||
|
||||
updated, err := meta.GetRepositoryTimestamp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated != nil {
|
||||
provisioning.Updated = updated.Unix()
|
||||
if source.TimestampMillis > 0 {
|
||||
provisioning.Updated = time.UnixMilli(source.TimestampMillis).Unix()
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
@@ -2023,13 +2022,13 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (unstructured.Unstructured, error) {
|
||||
func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (*unstructured.Unstructured, error) {
|
||||
uid := cmd.GetDashboardModel().UID
|
||||
if uid == "" {
|
||||
uid = uuid.NewString()
|
||||
}
|
||||
|
||||
finalObj := unstructured.Unstructured{
|
||||
finalObj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{},
|
||||
}
|
||||
|
||||
@@ -2057,7 +2056,7 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names
|
||||
finalObj.SetNamespace(namespace)
|
||||
finalObj.SetGroupVersionKind(dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind())
|
||||
|
||||
meta, err := utils.MetaAccessor(&finalObj)
|
||||
meta, err := utils.MetaAccessor(finalObj)
|
||||
if err != nil {
|
||||
return finalObj, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,13 +10,12 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard"
|
||||
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||
@@ -540,31 +540,38 @@ func TestGetProvisionedDashboardData(t *testing.T) {
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
provisioningTimestamp := int64(1234567)
|
||||
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
"labels": map[string]any{
|
||||
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "uid",
|
||||
"labels": map[string]interface{}{
|
||||
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
||||
},
|
||||
"annotations": map[string]interface{}{
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "test",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()),
|
||||
},
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
"spec": map[string]interface{}{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
},
|
||||
}}, nil).Once()
|
||||
}, nil).Once()
|
||||
repo := "test"
|
||||
k8sCliMock.On("Search", mock.Anything, int64(1),
|
||||
mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
// ensure the prefix is added to the query
|
||||
return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo)
|
||||
// make sure the kind is added to the query
|
||||
return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck
|
||||
req.Options.Fields[1].Values[0] == repo
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{},
|
||||
@@ -573,8 +580,9 @@ func TestGetProvisionedDashboardData(t *testing.T) {
|
||||
TotalHits: 0,
|
||||
}, nil).Once()
|
||||
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
// ensure the prefix is added to the query
|
||||
return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo)
|
||||
// make sure the kind is added to the query
|
||||
return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck
|
||||
req.Options.Fields[1].Values[0] == repo
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -611,7 +619,7 @@ func TestGetProvisionedDashboardData(t *testing.T) {
|
||||
Name: "test",
|
||||
ExternalID: "path/to/file",
|
||||
CheckSum: "hash",
|
||||
Updated: 1735689600,
|
||||
Updated: provisioningTimestamp,
|
||||
})
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
})
|
||||
@@ -639,21 +647,25 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) {
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
provisioningTimestamp := int64(1234567)
|
||||
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "uid",
|
||||
"labels": map[string]any{
|
||||
"labels": map[string]interface{}{
|
||||
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
"annotations": map[string]interface{}{
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "test",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()),
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"spec": map[string]interface{}{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
@@ -701,7 +713,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) {
|
||||
Name: "test",
|
||||
ExternalID: "path/to/file",
|
||||
CheckSum: "hash",
|
||||
Updated: 1735689600,
|
||||
Updated: provisioningTimestamp,
|
||||
})
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
})
|
||||
@@ -729,21 +741,25 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
provisioningTimestamp := int64(1234567)
|
||||
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "uid",
|
||||
"labels": map[string]any{
|
||||
"labels": map[string]interface{}{
|
||||
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
"annotations": map[string]interface{}{
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "test",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()),
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"spec": map[string]interface{}{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
@@ -784,7 +800,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
|
||||
Name: "test",
|
||||
ExternalID: "path/to/file",
|
||||
CheckSum: "hash",
|
||||
Updated: 1735689600,
|
||||
Updated: provisioningTimestamp,
|
||||
})
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
})
|
||||
@@ -826,10 +842,11 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "orphaned",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
@@ -839,8 +856,8 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
"metadata": map[string]any{
|
||||
"name": "uid2",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.PluginIDRepoName,
|
||||
utils.AnnoKeyRepoHash: "app",
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindPlugin),
|
||||
utils.AnnoKeyManagerIdentity: "app",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
@@ -850,16 +867,17 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
"metadata": map[string]any{
|
||||
"name": "uid3",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "orphaned",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
}}, nil).Once()
|
||||
k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
|
||||
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin"
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -889,7 +907,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
}, nil).Once()
|
||||
|
||||
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
|
||||
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin"
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -960,10 +978,11 @@ func TestUnprovisionDashboard(t *testing.T) {
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
utils.AnnoKeyManagerKind: utils.ManagerKindClassicFP, // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "test",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
@@ -983,7 +1002,7 @@ func TestUnprovisionDashboard(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
// should update it to be without annotations
|
||||
k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, mock.Anything).Return(dashWithoutAnnotations, nil)
|
||||
k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything).Return(dashWithoutAnnotations, nil)
|
||||
k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
|
||||
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
@@ -1054,8 +1073,9 @@ func TestGetDashboardsByPluginID(t *testing.T) {
|
||||
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil)
|
||||
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.PluginIDRepoName &&
|
||||
req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing"
|
||||
return ( // gofmt comment helper
|
||||
req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindPlugin) &&
|
||||
req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "testing")
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -1989,14 +2009,16 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
|
||||
query := &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: 1,
|
||||
}
|
||||
provisioningTimestamp := int64(1234567)
|
||||
dashboardUnstructuredProvisioned := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
||||
utils.AnnoKeyManagerIdentity: "test",
|
||||
utils.AnnoKeySourceChecksum: "hash",
|
||||
utils.AnnoKeySourcePath: "path/to/file",
|
||||
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()),
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
@@ -2056,7 +2078,7 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
|
||||
Name: "test",
|
||||
ExternalID: "path/to/file",
|
||||
CheckSum: "hash",
|
||||
Updated: 1735689600,
|
||||
Updated: provisioningTimestamp,
|
||||
},
|
||||
},
|
||||
}, res) // only should return the one provisioned dashboard
|
||||
|
||||
@@ -29,10 +29,11 @@ var (
|
||||
resource.SEARCH_FIELD_CREATED_BY,
|
||||
resource.SEARCH_FIELD_UPDATED,
|
||||
resource.SEARCH_FIELD_UPDATED_BY,
|
||||
resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
resource.SEARCH_FIELD_REPOSITORY_PATH,
|
||||
resource.SEARCH_FIELD_REPOSITORY_HASH,
|
||||
resource.SEARCH_FIELD_REPOSITORY_TIME,
|
||||
resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
resource.SEARCH_FIELD_MANAGER_ID,
|
||||
resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
resource.SEARCH_FIELD_SOURCE_CHECKSUM,
|
||||
resource.SEARCH_FIELD_SOURCE_TIME,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func (ss *FolderUnifiedStoreImpl) UnstructuredToLegacyFolder(ctx context.Context, item *unstructured.Unstructured) (*folder.Folder, error) {
|
||||
@@ -57,7 +58,7 @@ func (ss *FolderUnifiedStoreImpl) UnstructuredToLegacyFolder(ctx context.Context
|
||||
if updater.UID == "" {
|
||||
updater = creator
|
||||
}
|
||||
|
||||
manager, _ := meta.GetManagerProperties()
|
||||
return &folder.Folder{
|
||||
UID: uid,
|
||||
Title: title,
|
||||
@@ -65,7 +66,7 @@ func (ss *FolderUnifiedStoreImpl) UnstructuredToLegacyFolder(ctx context.Context
|
||||
ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck
|
||||
ParentUID: meta.GetFolder(),
|
||||
Version: int(meta.GetGeneration()),
|
||||
Repository: meta.GetRepositoryName(),
|
||||
ManagedBy: manager.Kind,
|
||||
|
||||
URL: url,
|
||||
Created: created,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
@@ -63,7 +64,7 @@ func TestFolderConversions(t *testing.T) {
|
||||
Title: "test folder",
|
||||
Description: "Something set in the file",
|
||||
URL: "/dashboards/f/be79sztagf20wd/test-folder",
|
||||
Repository: "example-repo",
|
||||
ManagedBy: utils.ManagerKindRepo,
|
||||
Created: created,
|
||||
Updated: created.Add(time.Hour * 5),
|
||||
CreatedBy: 10,
|
||||
|
||||
@@ -39,6 +39,10 @@ func (ss *FolderStoreImpl) Create(ctx context.Context, cmd folder.CreateFolderCo
|
||||
return nil, folder.ErrBadRequest.Errorf("missing UID")
|
||||
}
|
||||
|
||||
if cmd.UID == cmd.ParentUID {
|
||||
return nil, folder.ErrFolderCannotBeParentOfItself
|
||||
}
|
||||
|
||||
var foldr *folder.Folder
|
||||
/*
|
||||
version := 1
|
||||
|
||||
@@ -60,6 +60,18 @@ func TestIntegrationCreate(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating a folder with itself as a parent should fail", func(t *testing.T) {
|
||||
uid := util.GenerateShortUID()
|
||||
_, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
|
||||
Title: folderTitle,
|
||||
OrgID: orgID,
|
||||
ParentUID: uid,
|
||||
Description: folderDsc,
|
||||
UID: uid,
|
||||
})
|
||||
require.ErrorIs(t, err, folder.ErrFolderCannotBeParentOfItself)
|
||||
})
|
||||
|
||||
t.Run("creating a folder without providing a parent should default to the empty parent folder", func(t *testing.T) {
|
||||
uid := util.GenerateShortUID()
|
||||
f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
|
||||
|
||||
@@ -77,6 +77,10 @@ func (ss *FolderUnifiedStoreImpl) Delete(ctx context.Context, UIDs []string, org
|
||||
func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) {
|
||||
obj, err := ss.k8sclient.Get(ctx, cmd.UID, cmd.OrgID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, dashboards.ErrFolderNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
updated := obj.DeepCopy()
|
||||
@@ -144,7 +148,7 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa
|
||||
parentUid := q.UID
|
||||
|
||||
for parentUid != "" {
|
||||
out, err := ss.k8sclient.Get(ctx, parentUid, q.OrgID, v1.GetOptions{})
|
||||
folder, err := ss.Get(ctx, folder.GetFolderQuery{UID: &parentUid, OrgID: q.OrgID})
|
||||
if err != nil {
|
||||
var statusError *apierrors.StatusError
|
||||
if errors.As(err, &statusError) && statusError.ErrStatus.Code == http.StatusForbidden {
|
||||
@@ -155,11 +159,6 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder, err := ss.UnstructuredToLegacyFolder(ctx, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentUid = folder.ParentUID
|
||||
hits = append(hits, folder)
|
||||
}
|
||||
@@ -183,6 +182,15 @@ func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetC
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
if q.UID != "" {
|
||||
// the original get children query fails if the parent folder does not exist
|
||||
// check that the parent exists first
|
||||
_, err := ss.Get(ctx, folder.GetFolderQuery{UID: &q.UID, OrgID: q.OrgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req := &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Fields: []*resource.Requirement{
|
||||
@@ -229,12 +237,7 @@ func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetC
|
||||
}
|
||||
|
||||
// search only returns a subset of info, get all info of the folder
|
||||
item, err := ss.k8sclient.Get(ctx, item.Name, q.OrgID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := ss.UnstructuredToLegacyFolder(ctx, item)
|
||||
f, err := ss.Get(ctx, folder.GetFolderQuery{UID: &item.Name, OrgID: q.OrgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -411,6 +414,10 @@ func getDescendants(nodes map[string]*folder.Folder, tree map[string]map[string]
|
||||
func (ss *FolderUnifiedStoreImpl) CountFolderContent(ctx context.Context, orgID int64, ancestor_uid string) (folder.DescendantCounts, error) {
|
||||
counts, err := ss.k8sclient.Get(ctx, ancestor_uid, orgID, v1.GetOptions{}, "counts")
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, dashboards.ErrFolderNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
)
|
||||
|
||||
@@ -170,6 +172,16 @@ func TestGetParents(t *testing.T) {
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "parenttwo", result[0].UID)
|
||||
})
|
||||
t.Run("should stop if parent folder is not found", func(t *testing.T) {
|
||||
mockCli.On("Get", mock.Anything, "parentone", orgID, mock.Anything, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "folders.folder.grafana.app", Resource: "folder"}, "parentone")).Once()
|
||||
|
||||
_, err := store.GetParents(ctx, folder.GetParentsQuery{
|
||||
UID: "parentone",
|
||||
OrgID: orgID,
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, dashboards.ErrFolderNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChildren(t *testing.T) {
|
||||
@@ -213,6 +225,11 @@ func TestGetChildren(t *testing.T) {
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder1", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder1"},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder2", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder2"},
|
||||
@@ -235,6 +252,47 @@ func TestGetChildren(t *testing.T) {
|
||||
require.Equal(t, "folder3", result[1].UID)
|
||||
})
|
||||
|
||||
t.Run("should return an error if the folder is not found", func(t *testing.T) {
|
||||
mockCli.On("Search", mock.Anything, orgID, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_FOLDER,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{"folder1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: folderSearchLimit, // should default to folderSearchLimit
|
||||
Offset: 0, // should be set as limit * (page - 1)
|
||||
Page: 1, // should be set to 1 by default
|
||||
}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: "folder", Type: resource.ResourceTableColumnDefinition_STRING},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: "folder2", Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: "folder3", Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder1", orgID, mock.Anything, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "folders.folder.grafana.app", Resource: "folder"}, "folder1")).Once()
|
||||
|
||||
_, err := store.GetChildren(ctx, folder.GetChildrenQuery{
|
||||
UID: "folder1",
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.ErrorIs(t, err, dashboards.ErrFolderNotFound)
|
||||
})
|
||||
|
||||
t.Run("pages should be able to be set, general folder should be turned to empty string, and folder uids should be passed in", func(t *testing.T) {
|
||||
mockCli.On("Search", mock.Anything, orgID, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
@@ -301,6 +359,11 @@ func TestGetChildren(t *testing.T) {
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil)
|
||||
mockCli.On("Get", mock.Anything, "folder", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder"},
|
||||
},
|
||||
}, nil)
|
||||
mockCli.On("Get", mock.Anything, accesscontrol.K6FolderUID, orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": accesscontrol.K6FolderUID},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package folder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
@@ -20,6 +22,7 @@ var ErrInternal = errutil.Internal("folder.internal")
|
||||
var ErrCircularReference = errutil.BadRequest("folder.circular-reference", errutil.WithPublicMessage("Circular reference detected"))
|
||||
var ErrTargetRegistrySrvConflict = errutil.Internal("folder.target-registry-srv-conflict")
|
||||
var ErrFolderNotEmpty = errutil.BadRequest("folder.not-empty", errutil.WithPublicMessage("Folder cannot be deleted: folder is not empty"))
|
||||
var ErrFolderCannotBeParentOfItself = errors.New("folder cannot be parent of itself")
|
||||
|
||||
const (
|
||||
GeneralFolderUID = "general"
|
||||
@@ -54,10 +57,10 @@ type Folder struct {
|
||||
Fullpath string `xorm:"fullpath"`
|
||||
FullpathUIDs string `xorm:"fullpath_uids"`
|
||||
|
||||
// When the folder belongs to a repository
|
||||
// The folder is managed by an external process
|
||||
// NOTE: this is only populated when folders are managed by unified storage
|
||||
// This is not ever used by xorm, but the translation functions flow through this type
|
||||
Repository string `json:"repository,omitempty"`
|
||||
ManagedBy utils.ManagerKind `json:"managedBy,omitempty"`
|
||||
}
|
||||
|
||||
var GeneralFolder = Folder{ID: 0, Title: "General"}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -151,6 +153,32 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
||||
require.NotNil(t, remaining)
|
||||
})
|
||||
|
||||
t.Run("with no access to the datasource should return 403", func(t *testing.T) {
|
||||
acFake := &acfakes.FakeRuleService{}
|
||||
srv, _, _, _ := createConvertPrometheusSrv(t, withFakeAccessControlRuleService(acFake))
|
||||
|
||||
acFake.AuthorizeRuleChangesFunc = func(context.Context, identity.Requester, *store.GroupDelta) error {
|
||||
return datasources.ErrDataSourceAccessDenied
|
||||
}
|
||||
|
||||
rc := createRequestCtx()
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup)
|
||||
require.Equal(t, http.StatusForbidden, response.Status())
|
||||
require.Contains(t, string(response.Body()), "data source access denied")
|
||||
})
|
||||
|
||||
t.Run("when alert rule quota limit exceeded", func(t *testing.T) {
|
||||
quotas := &provisioning.MockQuotaChecker{}
|
||||
quotas.EXPECT().LimitExceeded()
|
||||
|
||||
srv, _, _, _ := createConvertPrometheusSrv(t, withQuotaChecker(quotas))
|
||||
|
||||
rc := createRequestCtx()
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup)
|
||||
require.Equal(t, http.StatusForbidden, response.Status())
|
||||
require.Contains(t, string(response.Body()), "quota has been exceeded")
|
||||
})
|
||||
|
||||
t.Run("with valid pause header values should return 202", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -749,7 +777,9 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
type convertPrometheusSrvOptions struct {
|
||||
provenanceStore provisioning.ProvisioningStore
|
||||
provenanceStore provisioning.ProvisioningStore
|
||||
fakeAccessControlRuleService *acfakes.FakeRuleService
|
||||
quotaChecker *provisioning.MockQuotaChecker
|
||||
}
|
||||
|
||||
type convertPrometheusSrvOptionsFunc func(*convertPrometheusSrvOptions)
|
||||
@@ -760,11 +790,29 @@ func withProvenanceStore(store provisioning.ProvisioningStore) convertPrometheus
|
||||
}
|
||||
}
|
||||
|
||||
func withFakeAccessControlRuleService(service *acfakes.FakeRuleService) convertPrometheusSrvOptionsFunc {
|
||||
return func(opts *convertPrometheusSrvOptions) {
|
||||
opts.fakeAccessControlRuleService = service
|
||||
}
|
||||
}
|
||||
|
||||
func withQuotaChecker(checker *provisioning.MockQuotaChecker) convertPrometheusSrvOptionsFunc {
|
||||
return func(opts *convertPrometheusSrvOptions) {
|
||||
opts.quotaChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, datasources.CacheService, *fakes.RuleStore, *foldertest.FakeService) {
|
||||
t.Helper()
|
||||
|
||||
// By default the quota checker will allow the operation
|
||||
quotas := &provisioning.MockQuotaChecker{}
|
||||
quotas.EXPECT().LimitOK()
|
||||
|
||||
options := convertPrometheusSrvOptions{
|
||||
provenanceStore: fakes.NewFakeProvisioningStore(),
|
||||
provenanceStore: fakes.NewFakeProvisioningStore(),
|
||||
fakeAccessControlRuleService: &acfakes.FakeRuleService{},
|
||||
quotaChecker: quotas,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -782,23 +830,20 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
|
||||
}
|
||||
dsCache.DataSources = append(dsCache.DataSources, ds)
|
||||
|
||||
quotas := &provisioning.MockQuotaChecker{}
|
||||
quotas.EXPECT().LimitOK()
|
||||
|
||||
folderService := foldertest.NewFakeService()
|
||||
|
||||
alertRuleService := provisioning.NewAlertRuleService(
|
||||
ruleStore,
|
||||
options.provenanceStore,
|
||||
folderService,
|
||||
quotas,
|
||||
options.quotaChecker,
|
||||
&provisioning.NopTransactionManager{},
|
||||
60,
|
||||
10,
|
||||
100,
|
||||
log.New("test"),
|
||||
&provisioning.NotificationSettingsValidatorProviderFake{},
|
||||
&acfakes.FakeRuleService{},
|
||||
options.fakeAccessControlRuleService,
|
||||
)
|
||||
|
||||
cfg := &setting.UnifiedAlertingSettings{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -42,5 +43,11 @@ func errorToResponse(err error) response.Response {
|
||||
if errors.Is(err, errFolderAccess) {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
if errors.Is(err, datasources.ErrDataSourceAccessDenied) {
|
||||
return ErrResp(http.StatusForbidden, err, "")
|
||||
}
|
||||
if errors.Is(err, models.ErrQuotaReached) {
|
||||
return ErrResp(http.StatusForbidden, err, "")
|
||||
}
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
}
|
||||
@@ -832,9 +832,9 @@ func (a *State) transition(alertRule *models.AlertRule, result eval.Result, extr
|
||||
}
|
||||
|
||||
func resultStateReason(result eval.Result, rule *models.AlertRule) string {
|
||||
if rule.ExecErrState == models.KeepLastErrState || rule.NoDataState == models.KeepLast {
|
||||
if result.State == eval.Error && rule.ExecErrState == models.KeepLastErrState ||
|
||||
result.State == eval.NoData && rule.NoDataState == models.KeepLast {
|
||||
return models.ConcatReasons(result.State.String(), models.StateReasonKeepLast)
|
||||
}
|
||||
|
||||
return result.State.String()
|
||||
}
|
||||
@@ -1132,3 +1132,61 @@ func TestPatch(t *testing.T) {
|
||||
assert.EqualValues(t, orig.Annotations, state.Annotations)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultStateReason(t *testing.T) {
|
||||
gen := ngmodels.RuleGen
|
||||
tests := []struct {
|
||||
name string
|
||||
result eval.Result
|
||||
rule *ngmodels.AlertRule
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Error state with KeepLast",
|
||||
result: eval.Result{
|
||||
State: eval.Error,
|
||||
},
|
||||
rule: gen.With(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.KeepLastErrState)).GenerateRef(),
|
||||
expected: "Error, KeepLast",
|
||||
},
|
||||
{
|
||||
name: "Error state without KeepLast",
|
||||
result: eval.Result{
|
||||
State: eval.Error,
|
||||
},
|
||||
rule: gen.With(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.ErrorErrState)).GenerateRef(),
|
||||
expected: "Error",
|
||||
},
|
||||
{
|
||||
name: "NoData state with KeepLast state",
|
||||
result: eval.Result{
|
||||
State: eval.NoData,
|
||||
},
|
||||
rule: gen.With(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.KeepLast)).GenerateRef(),
|
||||
expected: "NoData, KeepLast",
|
||||
},
|
||||
{
|
||||
name: "NoData state without KeepLast",
|
||||
result: eval.Result{
|
||||
State: eval.NoData,
|
||||
},
|
||||
rule: gen.With(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.NoData)).GenerateRef(),
|
||||
expected: "NoData",
|
||||
},
|
||||
{
|
||||
name: "Normal state",
|
||||
result: eval.Result{
|
||||
State: eval.NoData,
|
||||
},
|
||||
rule: gen.With(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.ErrorErrState), ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.NoData)).GenerateRef(),
|
||||
expected: "NoData",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := resultStateReason(tc.result, tc.rule)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -211,15 +211,27 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb
|
||||
}
|
||||
|
||||
case "annotations":
|
||||
for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() {
|
||||
if sub == "list" {
|
||||
for iter.ReadArray() {
|
||||
v := iter.Read()
|
||||
logf("[dash.anno] %v\n", v)
|
||||
}
|
||||
} else {
|
||||
iter.Skip()
|
||||
switch iter.WhatIsNext() {
|
||||
case jsoniter.ArrayValue:
|
||||
// dashboards v2 is an array
|
||||
for iter.ReadArray() {
|
||||
v := iter.Read()
|
||||
logf("[dash.anno] %v\n", v)
|
||||
}
|
||||
case jsoniter.ObjectValue:
|
||||
// dashboards v0/v1 are an object
|
||||
for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() {
|
||||
if sub == "list" {
|
||||
for iter.ReadArray() {
|
||||
v := iter.Read()
|
||||
logf("[dash.anno] %v\n", v)
|
||||
}
|
||||
} else {
|
||||
iter.Skip()
|
||||
}
|
||||
}
|
||||
default:
|
||||
iter.Skip()
|
||||
}
|
||||
|
||||
case "templating":
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
authtypes "github.com/grafana/authlib/types"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
authtypes "github.com/grafana/authlib/types"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
@@ -73,12 +74,6 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
|
||||
obj.SetResourceVersion("")
|
||||
obj.SetSelfLink("")
|
||||
|
||||
// Read+write will verify that repository format is accurate
|
||||
repo, err := obj.GetRepositoryInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj.SetRepositoryInfo(repo)
|
||||
obj.SetUpdatedBy("")
|
||||
obj.SetUpdatedTimestamp(nil)
|
||||
obj.SetCreatedBy(info.GetUID())
|
||||
@@ -136,12 +131,6 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
|
||||
obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck
|
||||
}
|
||||
|
||||
// Read+write will verify that origin format is accurate
|
||||
repo, err := obj.GetRepositoryInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj.SetRepositoryInfo(repo)
|
||||
obj.SetUpdatedBy(info.GetUID())
|
||||
obj.SetUpdatedTimestampMillis(time.Now().UnixMilli())
|
||||
|
||||
|
||||
@@ -6,16 +6,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
authtypes "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/rand"
|
||||
"k8s.io/apimachinery/pkg/api/apitesting"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
authtypes "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
|
||||
)
|
||||
|
||||
var scheme = runtime.NewScheme()
|
||||
@@ -87,11 +88,14 @@ func TestPrepareObjectForStorage(t *testing.T) {
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
require.NoError(t, err)
|
||||
now := time.Now()
|
||||
meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{
|
||||
Name: "test-repo",
|
||||
Path: "test/path",
|
||||
Hash: "hash",
|
||||
Timestamp: &now,
|
||||
meta.SetManagerProperties(utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "test-repo",
|
||||
})
|
||||
meta.SetSourceProperties(utils.SourceProperties{
|
||||
Path: "test/path",
|
||||
Checksum: "hash",
|
||||
TimestampMillis: now.UnixMilli(),
|
||||
})
|
||||
|
||||
encodedData, err := s.prepareObjectForStorage(ctx, obj)
|
||||
@@ -101,14 +105,16 @@ func TestPrepareObjectForStorage(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
meta, err = utils.MetaAccessor(newObject)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, meta.GetRepositoryHash(), "hash")
|
||||
require.Equal(t, meta.GetRepositoryName(), "test-repo")
|
||||
require.Equal(t, meta.GetRepositoryPath(), "test/path")
|
||||
ts, err := meta.GetRepositoryTimestamp()
|
||||
require.NoError(t, err)
|
||||
parsed, err := time.Parse(time.RFC3339, now.UTC().Format(time.RFC3339))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ts, &parsed)
|
||||
|
||||
m, ok := meta.GetManagerProperties()
|
||||
require.True(t, ok)
|
||||
s, ok := meta.GetSourceProperties()
|
||||
require.True(t, ok)
|
||||
|
||||
require.Equal(t, m.Identity, "test-repo")
|
||||
require.Equal(t, s.Checksum, "hash")
|
||||
require.Equal(t, s.Path, "test/path")
|
||||
require.Equal(t, s.TimestampMillis, now.UnixMilli())
|
||||
})
|
||||
|
||||
s.opts.RequireDeprecatedInternalID = true
|
||||
|
||||
@@ -102,7 +102,10 @@ type IndexableDocument struct {
|
||||
References ResourceReferences `json:"reference,omitempty"`
|
||||
|
||||
// When the resource is managed by an upstream repository
|
||||
RepoInfo *utils.ResourceRepositoryInfo `json:"repo,omitempty"`
|
||||
Manager *utils.ManagerProperties `json:"manager,omitempty"`
|
||||
|
||||
// When the manager knows about file paths
|
||||
Source *utils.SourceProperties `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
func (m *IndexableDocument) Type() string {
|
||||
@@ -173,7 +176,14 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
|
||||
CreatedBy: obj.GetCreatedBy(),
|
||||
UpdatedBy: obj.GetUpdatedBy(),
|
||||
}
|
||||
doc.RepoInfo, _ = obj.GetRepositoryInfo()
|
||||
m, ok := obj.GetManagerProperties()
|
||||
if ok {
|
||||
doc.Manager = &m
|
||||
}
|
||||
s, ok := obj.GetSourceProperties()
|
||||
if ok {
|
||||
doc.Source = &s
|
||||
}
|
||||
ts := obj.GetCreationTimestamp()
|
||||
if !ts.Time.IsZero() {
|
||||
doc.Created = ts.Time.UnixMilli()
|
||||
@@ -265,10 +275,11 @@ const SEARCH_FIELD_CREATED_BY = "createdBy"
|
||||
const SEARCH_FIELD_UPDATED = "updated"
|
||||
const SEARCH_FIELD_UPDATED_BY = "updatedBy"
|
||||
|
||||
const SEARCH_FIELD_REPOSITORY_NAME = "repo.name"
|
||||
const SEARCH_FIELD_REPOSITORY_PATH = "repo.path"
|
||||
const SEARCH_FIELD_REPOSITORY_HASH = "repo.hash"
|
||||
const SEARCH_FIELD_REPOSITORY_TIME = "repo.time"
|
||||
const SEARCH_FIELD_MANAGER_KIND = "manager.kind"
|
||||
const SEARCH_FIELD_MANAGER_ID = "manager.id"
|
||||
const SEARCH_FIELD_SOURCE_PATH = "source.path"
|
||||
const SEARCH_FIELD_SOURCE_CHECKSUM = "source.checksum"
|
||||
const SEARCH_FIELD_SOURCE_TIME = "source.timestampMillis"
|
||||
|
||||
const SEARCH_FIELD_SCORE = "_score" // the match score
|
||||
const SEARCH_FIELD_EXPLAIN = "_explain" // score explanation as JSON object
|
||||
|
||||
@@ -33,17 +33,20 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"resource": "playlists",
|
||||
"name": "test1"
|
||||
},
|
||||
"name": "test1",
|
||||
"rv": 10,
|
||||
"title": "test playlist unified storage",
|
||||
"title_phrase": "test playlist unified storage",
|
||||
"created": 1717236672000,
|
||||
"createdBy": "user:ABC",
|
||||
"updatedBy": "user:XYZ",
|
||||
"name": "test1",
|
||||
"repo": {
|
||||
"name": "something",
|
||||
"manager": {
|
||||
"kind": "repo",
|
||||
"id": "something"
|
||||
},
|
||||
"source": {
|
||||
"path": "path/in/system.json",
|
||||
"hash": "xyz"
|
||||
"checksum": "xyz"
|
||||
}
|
||||
}`, string(jj))
|
||||
}`, string(jj))
|
||||
}
|
||||
@@ -456,12 +456,9 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := obj.GetRepositoryInfo()
|
||||
if err != nil {
|
||||
return nil, NewBadRequestError("invalid repository info")
|
||||
}
|
||||
if repo != nil {
|
||||
err = s.writeHooks.CanWriteValueFromRepository(ctx, user, repo.Name)
|
||||
m, ok := obj.GetManagerProperties()
|
||||
if ok && m.Kind == utils.ManagerKindRepo {
|
||||
err = s.writeHooks.CanWriteValueFromRepository(ctx, user, m.Identity)
|
||||
if err != nil {
|
||||
return nil, AsErrorResult(err)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ import (
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
|
||||
index "github.com/blevesearch/bleve_index_api"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -304,19 +305,20 @@ func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.Li
|
||||
found, err := b.index.SearchInContext(ctx, &bleve.SearchRequest{
|
||||
Query: &query.TermQuery{
|
||||
Term: req.Name,
|
||||
FieldVal: resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
FieldVal: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
},
|
||||
Fields: []string{
|
||||
resource.SEARCH_FIELD_TITLE,
|
||||
resource.SEARCH_FIELD_FOLDER,
|
||||
resource.SEARCH_FIELD_REPOSITORY_NAME,
|
||||
resource.SEARCH_FIELD_REPOSITORY_PATH,
|
||||
resource.SEARCH_FIELD_REPOSITORY_HASH,
|
||||
resource.SEARCH_FIELD_REPOSITORY_TIME,
|
||||
resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
resource.SEARCH_FIELD_MANAGER_ID,
|
||||
resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
resource.SEARCH_FIELD_SOURCE_CHECKSUM,
|
||||
resource.SEARCH_FIELD_SOURCE_TIME,
|
||||
},
|
||||
Sort: search.SortOrder{
|
||||
&search.SortField{
|
||||
Field: resource.SEARCH_FIELD_REPOSITORY_PATH,
|
||||
Field: resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
Type: search.SortFieldAsString,
|
||||
Desc: false,
|
||||
},
|
||||
@@ -347,6 +349,10 @@ func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.Li
|
||||
if ok {
|
||||
return intV
|
||||
}
|
||||
floatV, ok := v.(float64)
|
||||
if ok {
|
||||
return int64(floatV)
|
||||
}
|
||||
str, ok := v.(string)
|
||||
if ok {
|
||||
t, _ := time.Parse(time.RFC3339, str)
|
||||
@@ -359,9 +365,9 @@ func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.Li
|
||||
for _, hit := range found.Hits {
|
||||
item := &resource.ListRepositoryObjectsResponse_Item{
|
||||
Object: &resource.ResourceKey{},
|
||||
Hash: asString(hit.Fields[resource.SEARCH_FIELD_REPOSITORY_HASH]),
|
||||
Path: asString(hit.Fields[resource.SEARCH_FIELD_REPOSITORY_PATH]),
|
||||
Time: asTime(hit.Fields[resource.SEARCH_FIELD_REPOSITORY_TIME]),
|
||||
Hash: asString(hit.Fields[resource.SEARCH_FIELD_SOURCE_CHECKSUM]),
|
||||
Path: asString(hit.Fields[resource.SEARCH_FIELD_SOURCE_PATH]),
|
||||
Time: asTime(hit.Fields[resource.SEARCH_FIELD_SOURCE_TIME]),
|
||||
Title: asString(hit.Fields[resource.SEARCH_FIELD_TITLE]),
|
||||
Folder: asString(hit.Fields[resource.SEARCH_FIELD_FOLDER]),
|
||||
}
|
||||
@@ -379,7 +385,7 @@ func (b *bleveIndex) CountRepositoryObjects(ctx context.Context) ([]*resource.Co
|
||||
Query: bleve.NewMatchAllQuery(),
|
||||
Size: 0,
|
||||
Facets: bleve.FacetsRequest{
|
||||
"count": bleve.NewFacetRequest(resource.SEARCH_FIELD_REPOSITORY_NAME, 1000), // typically less then 5
|
||||
"count": bleve.NewFacetRequest(resource.SEARCH_FIELD_MANAGER_ID, 1000), // typically less then 5
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -69,9 +69,9 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_FOLDER, folderMapping)
|
||||
|
||||
// Repositories
|
||||
repo := bleve.NewDocumentStaticMapping()
|
||||
repo.AddFieldMappingsAt("name", &mapping.FieldMapping{
|
||||
Name: "name",
|
||||
manager := bleve.NewDocumentStaticMapping()
|
||||
manager.AddFieldMappingsAt("kind", &mapping.FieldMapping{
|
||||
Name: "kind",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
@@ -79,7 +79,18 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
})
|
||||
repo.AddFieldMappingsAt("path", &mapping.FieldMapping{
|
||||
manager.AddFieldMappingsAt("id", &mapping.FieldMapping{
|
||||
Name: "id",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
})
|
||||
|
||||
source := bleve.NewDocumentStaticMapping()
|
||||
source.AddFieldMappingsAt("path", &mapping.FieldMapping{
|
||||
Name: "path",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -88,8 +99,8 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
})
|
||||
repo.AddFieldMappingsAt("hash", &mapping.FieldMapping{
|
||||
Name: "hash",
|
||||
source.AddFieldMappingsAt("checksum", &mapping.FieldMapping{
|
||||
Name: "checksum",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
@@ -97,9 +108,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
})
|
||||
repo.AddFieldMappingsAt("time", mapping.NewDateTimeFieldMapping())
|
||||
source.AddFieldMappingsAt("timestampMillis", mapping.NewNumericFieldMapping())
|
||||
|
||||
mapper.AddSubDocumentMapping("repo", repo)
|
||||
mapper.AddSubDocumentMapping("manager", manager)
|
||||
mapper.AddSubDocumentMapping("source", source)
|
||||
|
||||
labelMapper := bleve.NewDocumentMapping()
|
||||
mapper.AddSubDocumentMapping(resource.SEARCH_FIELD_LABELS, labelMapper)
|
||||
|
||||
@@ -25,11 +25,14 @@ func TestDocumentMapping(t *testing.T) {
|
||||
"x": "y",
|
||||
},
|
||||
RV: 1234,
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "nnn",
|
||||
Path: "ppp",
|
||||
Hash: "hhh",
|
||||
Timestamp: asTimePointer(1234),
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "rrr",
|
||||
},
|
||||
Source: &utils.SourceProperties{
|
||||
Path: "ppp",
|
||||
Checksum: "ooo",
|
||||
TimestampMillis: 1234,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,5 +46,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 13, len(doc.Fields))
|
||||
require.Equal(t, 14, len(doc.Fields))
|
||||
}
|
||||
@@ -7,20 +7,18 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
@@ -90,11 +88,14 @@ func TestBleveBackend(t *testing.T) {
|
||||
utils.LabelKeyDeprecatedInternalID: "10", // nolint:staticcheck
|
||||
},
|
||||
Tags: []string{"aa", "bb"},
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "repo-1",
|
||||
Path: "path/to/aaa.json",
|
||||
Hash: "xyz",
|
||||
Timestamp: asTimePointer(1609462800000), // 2021
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo-1",
|
||||
},
|
||||
Source: &utils.SourceProperties{
|
||||
Path: "path/to/aaa.json",
|
||||
Checksum: "xyz",
|
||||
TimestampMillis: 1609462800000, // 2021
|
||||
},
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
@@ -119,11 +120,14 @@ func TestBleveBackend(t *testing.T) {
|
||||
"region": "east",
|
||||
utils.LabelKeyDeprecatedInternalID: "11", // nolint:staticcheck
|
||||
},
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "repo-1",
|
||||
Path: "path/to/bbb.json",
|
||||
Hash: "hijk",
|
||||
Timestamp: asTimePointer(1640998800000), // 2022
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo-1",
|
||||
},
|
||||
Source: &utils.SourceProperties{
|
||||
Path: "path/to/bbb.json",
|
||||
Checksum: "hijk",
|
||||
TimestampMillis: 1640998800000, // 2022
|
||||
},
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
@@ -138,8 +142,11 @@ func TestBleveBackend(t *testing.T) {
|
||||
Title: "ccc (dash)",
|
||||
TitlePhrase: "ccc (dash)",
|
||||
Folder: "zzz",
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "repo2",
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo2",
|
||||
},
|
||||
Source: &utils.SourceProperties{
|
||||
Path: "path/in/repo2.yaml",
|
||||
},
|
||||
Fields: map[string]any{},
|
||||
@@ -263,6 +270,7 @@ func TestBleveBackend(t *testing.T) {
|
||||
jj, err := json.MarshalIndent(found, "", " ")
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("%s\n", string(jj))
|
||||
// NOTE "hash" -> "checksum" requires changing the protobuf
|
||||
require.JSONEq(t, `{
|
||||
"items": [
|
||||
{
|
||||
@@ -334,11 +342,14 @@ func TestBleveBackend(t *testing.T) {
|
||||
},
|
||||
Title: "zzz (folder)",
|
||||
TitlePhrase: "zzz (folder)",
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "repo-1",
|
||||
Path: "path/to/folder.json",
|
||||
Hash: "xxxx",
|
||||
Timestamp: asTimePointer(300),
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo-1",
|
||||
},
|
||||
Source: &utils.SourceProperties{
|
||||
Path: "path/to/folder.json",
|
||||
Checksum: "xxxx",
|
||||
TimestampMillis: 300,
|
||||
},
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
@@ -559,14 +570,6 @@ func TestGetSortFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func asTimePointer(milli int64) *time.Time {
|
||||
if milli > 0 {
|
||||
t := time.UnixMilli(milli)
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ authlib.AccessClient = (*StubAccessClient)(nil)
|
||||
|
||||
func NewStubAccessClient(permissions map[string]bool) *StubAccessClient {
|
||||
|
||||
@@ -81,14 +81,26 @@ func TestDashboardDocumentBuilder(t *testing.T) {
|
||||
|
||||
// Standard
|
||||
builder = resource.StandardDocumentBuilder()
|
||||
doSnapshotTests(t, builder, "folder", key, []string{
|
||||
doSnapshotTests(t, builder, "folder", &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
}, []string{
|
||||
"aaa",
|
||||
"bbb",
|
||||
})
|
||||
doSnapshotTests(t, builder, "playlist", key, []string{
|
||||
doSnapshotTests(t, builder, "playlist", &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
}, []string{
|
||||
"aaa",
|
||||
})
|
||||
doSnapshotTests(t, builder, "report", key, []string{
|
||||
doSnapshotTests(t, builder, "report", &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "reporting.grafana.app",
|
||||
Resource: "reports",
|
||||
}, []string{
|
||||
"aaa",
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"group": "folder.grafana.app",
|
||||
"resource": "folders",
|
||||
"name": "aaa"
|
||||
},
|
||||
"name": "aaa",
|
||||
@@ -11,7 +11,8 @@
|
||||
"title_phrase": "test-aaa",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
"repo": {
|
||||
"name": "SQL"
|
||||
"manager": {
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"creationTimestamp": "2024-11-01T19:42:22Z",
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:1",
|
||||
"grafana.app/originName": "SQL"
|
||||
"grafana.app/repoName": "MyGIT"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"group": "folder.grafana.app",
|
||||
"resource": "folders",
|
||||
"name": "bbb"
|
||||
},
|
||||
"name": "bbb",
|
||||
@@ -11,7 +11,8 @@
|
||||
"title_phrase": "test-bbb",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
"repo": {
|
||||
"name": "SQL"
|
||||
"manager": {
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"creationTimestamp": "2024-11-01T19:42:22Z",
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:1",
|
||||
"grafana.app/originName": "SQL"
|
||||
"grafana.app/repoName": "MyGIT"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"group": "playlist.grafana.app",
|
||||
"resource": "playlists",
|
||||
"name": "aaa"
|
||||
},
|
||||
"name": "aaa",
|
||||
@@ -10,10 +10,5 @@
|
||||
"title": "Test AAA",
|
||||
"title_phrase": "test aaa",
|
||||
"created": 1731336353000,
|
||||
"createdBy": "user:t000000001",
|
||||
"repo": {
|
||||
"name": "UI",
|
||||
"path": "/playlists/new",
|
||||
"hash": "Grafana v11.4.0-pre (c0de407fee)"
|
||||
}
|
||||
"createdBy": "user:t000000001"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"group": "reporting.grafana.app",
|
||||
"resource": "reports",
|
||||
"name": "aaa"
|
||||
},
|
||||
"name": "aaa",
|
||||
|
||||
@@ -68,7 +68,9 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.Group
|
||||
|
||||
wrap, err := utils.MetaAccessor(obj)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, wrap.GetRepositoryName()) // no SQL repo stub
|
||||
|
||||
m, _ := wrap.GetManagerProperties()
|
||||
require.Empty(t, m.Identity) // no SQL repo stub
|
||||
require.Equal(t, helper.Org1.Admin.Identity.GetUID(), wrap.GetCreatedBy())
|
||||
|
||||
// Commented out because the dynamic client does not like lists as sub-resource
|
||||
|
||||
+11
-8
@@ -15514,6 +15514,9 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"managedBy": {
|
||||
"$ref": "#/definitions/ManagerKind"
|
||||
},
|
||||
"orgId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -15529,10 +15532,6 @@
|
||||
"$ref": "#/definitions/Folder"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"description": "When the folder belongs to a repository\nNOTE: this is only populated when folders are managed by unified storage",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -15562,11 +15561,10 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"parentUid": {
|
||||
"type": "string"
|
||||
"managedBy": {
|
||||
"$ref": "#/definitions/ManagerKind"
|
||||
},
|
||||
"repository": {
|
||||
"description": "When the folder belongs to a repository\nNOTE: this is only populated when folders are managed by unified storage",
|
||||
"parentUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
@@ -17047,6 +17045,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ManagerKind": {
|
||||
"description": "It can be a user or a tool or a generic API client.\n+enum",
|
||||
"type": "string",
|
||||
"title": "ManagerKind is the type of manager, which is responsible for managing the resource."
|
||||
},
|
||||
"MassDeleteAnnotationsCmd": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user