-
Migration Successful!
+
+ Migration Successful!
+
Total:
{data.total}
@@ -220,7 +230,7 @@ export const MigrationSummary: React.FC = ({ visible, dat
)}
- Close
+ Close
diff --git a/public/app/features/api-keys/ApiKeysTable.tsx b/public/app/features/api-keys/ApiKeysTable.tsx
index f695fe5d4e1..983903d9c15 100644
--- a/public/app/features/api-keys/ApiKeysTable.tsx
+++ b/public/app/features/api-keys/ApiKeysTable.tsx
@@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { Button, DeleteButton, Icon, Stack, Tooltip, useTheme2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
+import { Trans, t } from 'app/core/internationalization';
import { AccessControlAction } from 'app/types';
import { ApiKey } from '../../types';
@@ -22,10 +23,18 @@ export const ApiKeysTable = ({ apiKeys, timeZone, onDelete, onMigrate }: Props)
- Name
- Role
- Expires
- Last used at
+
+ Name
+
+
+ Role
+
+
+ Expires
+
+
+ Last used at
+
@@ -51,10 +60,12 @@ export const ApiKeysTable = ({ apiKeys, timeZone, onDelete, onMigrate }: Props)
onMigrate(key)}>
- Migrate to service account
+
+ Migrate to service account
+
onDelete(key)}
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
diff --git a/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx b/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx
index 87ff5e72ed4..aa9f20fa292 100644
--- a/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx
+++ b/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, ConfirmModal, useStyles2 } from '@grafana/ui';
+import { Trans, t } from 'app/core/internationalization';
interface Props {
onMigrate: () => void;
@@ -21,15 +22,29 @@ export const MigrateToServiceAccountsCard = ({ onMigrate, apikeysCount, disabled
target="_blank"
rel="noopener noreferrer"
>
- Find out more about the migration here.
+
+ Find out more about the migration here.
+
);
- const migrationBoxDesc = Migrating all API keys will hide the API keys tab. ;
+ const migrationBoxDesc = (
+
+
+ Migrating all API keys will hide the API keys tab.
+
+
+ );
return (
<>
{apikeysCount > 0 && (
-
+
API keys are deprecated and will be removed from Grafana on Jan 31, 2025. Each API key will be migrated into
a service account with a token and will continue to work as they were. We encourage you to migrate your API
@@ -37,7 +52,9 @@ export const MigrateToServiceAccountsCard = ({ onMigrate, apikeysCount, disabled
setIsModalOpen(true)}>
- Migrate all service accounts
+
+ Migrate all service accounts
+
-
+
No API keys were found. If you reload the browser, this page will not be available anymore.
diff --git a/public/app/features/auth-config/AuthDrawer.tsx b/public/app/features/auth-config/AuthDrawer.tsx
index a9530e92d9f..b98e59855dd 100644
--- a/public/app/features/auth-config/AuthDrawer.tsx
+++ b/public/app/features/auth-config/AuthDrawer.tsx
@@ -5,6 +5,7 @@ import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Drawer, Text, TextLink, Switch, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
+import { t, Trans } from 'app/core/internationalization';
import { StoreState } from 'app/types';
import { loadSettings, saveSettings } from './state/actions';
@@ -84,10 +85,21 @@ export const AuthDrawerUnconnected = ({
const styles = useStyles2(getStyles);
return (
-
+
-
Advanced Auth
-
Enable insecure email lookup
+
+ Advanced Auth
+
+
+
+ Enable insecure email lookup
+
+
Allow users to use the same email address to log into Grafana with different identity providers.
@@ -100,7 +112,7 @@ export const AuthDrawerUnconnected = ({
onClick={resetButtonOnClick}
tooltip="This action will disregard any saved changes and load the configuration from the configuration file."
>
- Reset
+
Reset
);
diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx
index 717906f0f3a..7318db7441c 100644
--- a/public/app/features/auth-config/AuthProvidersListPage.tsx
+++ b/public/app/features/auth-config/AuthProvidersListPage.tsx
@@ -1,11 +1,12 @@
import { JSX, useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
-import { GrafanaEdition } from '@grafana/data/src/types/config';
+import { GrafanaEdition } from '@grafana/data/internal';
import { reportInteraction } from '@grafana/runtime';
import { Grid, TextLink, ToolbarButton } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { config } from 'app/core/config';
+import { Trans } from 'app/core/internationalization';
import { StoreState } from 'app/types';
import AuthDrawer from './AuthDrawer';
@@ -95,7 +96,7 @@ export const AuthConfigPageUnconnected = ({
actions={
config.buildInfo.edition !== GrafanaEdition.OpenSource && (
setShowDrawer(true)}>
- Auth settings
+ Auth settings
)
}
diff --git a/public/app/features/auth-config/ProviderConfigForm.tsx b/public/app/features/auth-config/ProviderConfigForm.tsx
index 0fb3567a16b..cc6372500b8 100644
--- a/public/app/features/auth-config/ProviderConfigForm.tsx
+++ b/public/app/features/auth-config/ProviderConfigForm.tsx
@@ -16,6 +16,7 @@ import {
Stack,
Switch,
} from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { FormPrompt } from '../../core/components/FormPrompt/FormPrompt';
import { Page } from '../../core/components/Page/Page';
@@ -54,7 +55,10 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
const additionalActionsMenu = (
{
setResetConfig(true);
@@ -163,7 +167,7 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
reset();
}}
/>
-
+
@@ -210,13 +214,13 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
{isSaving ? 'Saving...' : 'Save'}
- Discard
+ Discard
- Are you sure you want to reset this configuration?
+
+
+ Are you sure you want to reset this configuration?
+
+
After resetting these settings Grafana will use the provider configuration from the system (config
file/environment variables) if any.
diff --git a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx
index 904bf725950..e0a88e0afb3 100644
--- a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx
+++ b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
export interface Props {}
@@ -12,13 +13,19 @@ const ConfigureAuthCTA: React.FunctionComponent = () => {
- Configuration required
+
+ Configuration required
+
- You have no authentication configuration created at the moment.
+
+ You have no authentication configuration created at the moment.
+
- Refer to the documentation on how to configure authentication
+
+ Refer to the documentation on how to configure authentication
+
);
diff --git a/public/app/features/auth-config/components/ServerDiscoveryModal.tsx b/public/app/features/auth-config/components/ServerDiscoveryModal.tsx
index e54cdf0c213..ebd7a54addd 100644
--- a/public/app/features/auth-config/components/ServerDiscoveryModal.tsx
+++ b/public/app/features/auth-config/components/ServerDiscoveryModal.tsx
@@ -1,6 +1,7 @@
import { useForm } from 'react-hook-form';
import { Button, Input, Field, Modal } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { Trans } from '../../../core/internationalization';
import { ServerDiscoveryFormData } from '../types';
@@ -38,7 +39,15 @@ export const ServerDiscoveryModal = ({ isOpen, onClose, onSuccess, isLoading }:
};
return (
-
+
>
);
diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx
new file mode 100644
index 00000000000..f289e2f1fbb
--- /dev/null
+++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx
@@ -0,0 +1,450 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { useGetFolderQuery } from 'app/api/clients/folder';
+import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning';
+import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
+import { usePullRequestParam, useRepositoryList } from 'app/features/provisioning/hooks';
+
+import { FolderDTO } from '../../../types';
+
+import { NewProvisionedFolderForm } from './NewProvisionedFolderForm';
+
+jest.mock('@grafana/runtime', () => {
+ const actual = jest.requireActual('@grafana/runtime');
+ return {
+ ...actual,
+ getAppEvents: jest.fn(),
+ locationService: {
+ partial: jest.fn(),
+ },
+ config: {
+ ...actual.config,
+ },
+ };
+});
+
+jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => {
+ return {
+ validationSrv: {
+ validateNewFolderName: jest.fn(),
+ },
+ };
+});
+
+jest.mock('app/api/clients/provisioning', () => {
+ return {
+ useCreateRepositoryFilesWithPathMutation: jest.fn(),
+ };
+});
+
+jest.mock('app/api/clients/folder', () => {
+ return {
+ useGetFolderQuery: jest.fn(),
+ };
+});
+
+jest.mock('app/features/provisioning/hooks', () => {
+ return {
+ usePullRequestParam: jest.fn(),
+ useRepositoryList: jest.fn(),
+ };
+});
+
+jest.mock('react-router-dom-v5-compat', () => {
+ const actual = jest.requireActual('react-router-dom-v5-compat');
+ return {
+ ...actual,
+ useNavigate: () => jest.fn(),
+ };
+});
+
+// Mock the defaults
+jest.mock('../../dashboard-scene/saving/provisioned/defaults', () => {
+ return {
+ getDefaultWorkflow: jest.fn().mockReturnValue('write'),
+ getWorkflowOptions: jest.fn().mockReturnValue([
+ { label: 'Commit directly', value: 'write' },
+ { label: 'Create a branch', value: 'branch' },
+ ]),
+ };
+});
+
+interface Props {
+ onSubmit: () => void;
+ onCancel: () => void;
+ parentFolder: FolderDTO;
+}
+
+function setup(props: Partial = {}) {
+ const user = userEvent.setup();
+
+ const defaultProps: Props = {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ parentFolder: {
+ id: 1,
+ uid: 'folder-uid',
+ title: 'Parent Folder',
+ url: '/dashboards/f/folder-uid',
+ hasAcl: false,
+ canSave: true,
+ canEdit: true,
+ canAdmin: true,
+ canDelete: true,
+ repository: {
+ name: 'test-repo',
+ type: 'github',
+ },
+ } as unknown as FolderDTO,
+ ...props,
+ };
+
+ return {
+ user,
+ ...render( ),
+ props: defaultProps,
+ };
+}
+
+const mockRequest = {
+ isSuccess: false,
+ isError: false,
+ isLoading: false,
+ error: null,
+ data: { resource: { upsert: { metadata: { name: 'new-folder' } } } },
+};
+
+describe('NewProvisionedFolderForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup default mocks
+ const mockAppEvents = {
+ publish: jest.fn(),
+ };
+ (getAppEvents as jest.Mock).mockReturnValue(mockAppEvents);
+
+ (useRepositoryList as jest.Mock).mockReturnValue([
+ [
+ {
+ metadata: {
+ name: 'test-repo',
+ },
+ spec: {
+ title: 'Test Repository',
+ type: 'github',
+ github: {
+ url: 'https://github.com/grafana/grafana',
+ branch: 'main',
+ },
+ workflows: [{ name: 'default', path: 'workflows/default.yaml' }],
+ },
+ },
+ ],
+ false,
+ ]);
+
+ // Mock useGetFolderQuery
+ (useGetFolderQuery as jest.Mock).mockReturnValue({
+ data: {
+ metadata: {
+ annotations: {
+ 'source.path': '/dashboards',
+ },
+ },
+ },
+ isLoading: false,
+ isError: false,
+ });
+
+ // Mock usePullRequestParam
+ (usePullRequestParam as jest.Mock).mockReturnValue(null);
+
+ // Mock useCreateRepositoryFilesWithPathMutation
+ const mockCreate = jest.fn();
+
+ (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([mockCreate, mockRequest]);
+
+ (validationSrv.validateNewFolderName as jest.Mock).mockResolvedValue(true);
+ });
+
+ it('should render the form with correct fields', () => {
+ setup();
+
+ // Check if form elements are rendered
+ expect(screen.getByRole('textbox', { name: /folder name/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument();
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('should show loading state when repository data is loading', () => {
+ (useRepositoryList as jest.Mock).mockReturnValue([[], true]);
+
+ setup();
+
+ expect(screen.getByTestId('Spinner')).toBeInTheDocument();
+ });
+
+ it('should show error when repository is not found', () => {
+ (useRepositoryList as jest.Mock).mockReturnValue([null, false]);
+
+ setup();
+
+ expect(screen.getByText('Repository not found')).toBeInTheDocument();
+ });
+
+ it('should show branch field when branch workflow is selected', async () => {
+ const { user } = setup();
+
+ expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument();
+
+ const branchOption = screen.getByRole('radio', { name: /create a branch/i });
+ await user.click(branchOption);
+
+ expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument();
+ });
+
+ it('should validate folder name', async () => {
+ (validationSrv.validateNewFolderName as jest.Mock).mockRejectedValue(new Error('Folder name already exists'));
+
+ const { user } = setup();
+
+ const folderNameInput = screen.getByRole('textbox', { name: /folder name/i });
+ await user.clear(folderNameInput);
+ await user.type(folderNameInput, 'Existing Folder');
+
+ // Submit the form
+ const submitButton = screen.getByRole('button', { name: /^create$/i });
+ await user.click(submitButton);
+
+ // Wait for validation error to appear
+ await waitFor(() => {
+ expect(screen.getByText('Folder name already exists')).toBeInTheDocument();
+ });
+ });
+
+ it('should validate branch name', async () => {
+ const { user } = setup();
+
+ // Select branch workflow
+ const branchOption = screen.getByRole('radio', { name: /create a branch/i });
+ await user.click(branchOption);
+
+ // Enter invalid branch name
+ const branchInput = screen.getByRole('textbox', { name: /branch/i });
+ await user.clear(branchInput);
+ await user.type(branchInput, 'invalid//branch');
+
+ // Submit the form
+ const submitButton = screen.getByRole('button', { name: /^create$/i });
+ await user.click(submitButton);
+
+ // Wait for validation error to appear
+ await waitFor(() => {
+ expect(screen.getByText('Invalid branch name.')).toBeInTheDocument();
+ });
+ });
+
+ it('should create folder successfully', async () => {
+ const mockCreate = jest.fn();
+ (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([
+ mockCreate,
+ {
+ ...mockRequest,
+ isSuccess: true,
+ isError: false,
+ isLoading: false,
+ error: null,
+ },
+ ]);
+
+ const { user, props } = setup();
+
+ const folderNameInput = screen.getByRole('textbox', { name: /folder name/i });
+ const commentInput = screen.getByRole('textbox', { name: /comment/i });
+
+ await user.clear(folderNameInput);
+ await user.type(folderNameInput, 'New Test Folder');
+
+ await user.clear(commentInput);
+ await user.type(commentInput, 'Creating a new test folder');
+
+ // Submit the form
+ const submitButton = screen.getByRole('button', { name: /^create$/i });
+ await user.click(submitButton);
+
+ // Check if create was called with correct parameters
+ await waitFor(() => {
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ref: undefined, // write workflow uses undefined ref
+ name: 'test-repo',
+ message: 'Creating a new test folder',
+ body: {
+ title: 'New Test Folder',
+ type: 'folder',
+ },
+ })
+ );
+ });
+
+ // Check if onSubmit was called
+ expect(props.onSubmit).toHaveBeenCalled();
+ });
+
+ it('should create folder with branch workflow', async () => {
+ const mockCreate = jest.fn();
+ (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([
+ mockCreate,
+ {
+ ...mockRequest,
+ isSuccess: true,
+ isError: false,
+ isLoading: false,
+ error: null,
+ },
+ ]);
+
+ const { user } = setup();
+
+ // Fill form
+ const folderNameInput = screen.getByRole('textbox', { name: /folder name/i });
+ await user.clear(folderNameInput);
+ await user.type(folderNameInput, 'Branch Folder');
+
+ // Select branch workflow
+ const branchOption = screen.getByRole('radio', { name: /create a branch/i });
+ await user.click(branchOption);
+
+ // Enter branch name
+ const branchInput = screen.getByRole('textbox', { name: /branch/i });
+ await user.clear(branchInput);
+ await user.type(branchInput, 'feature/new-folder');
+
+ // Submit the form
+ const submitButton = screen.getByRole('button', { name: /^create$/i });
+ await user.click(submitButton);
+
+ // Check if create was called with correct parameters
+ await waitFor(() => {
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ref: 'feature/new-folder',
+ name: 'test-repo',
+ message: 'Create folder: Branch Folder',
+ body: {
+ title: 'Branch Folder',
+ type: 'folder',
+ },
+ })
+ );
+ });
+ });
+
+ it('should show error when folder creation fails', async () => {
+ const mockCreate = jest.fn();
+ (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([
+ mockCreate,
+ {
+ ...mockRequest,
+ isSuccess: false,
+ isError: true,
+ isLoading: false,
+ error: 'Failed to create folder',
+ },
+ ]);
+
+ const { user } = setup();
+
+ // Fill form
+ const folderNameInput = screen.getByRole('textbox', { name: /folder name/i });
+ await user.clear(folderNameInput);
+ await user.type(folderNameInput, 'Error Folder');
+
+ // Submit the form
+ const submitButton = screen.getByRole('button', { name: /^create$/i });
+ await user.click(submitButton);
+
+ // Check if error alert was published
+ await waitFor(() => {
+ const appEvents = getAppEvents();
+ expect(appEvents.publish).toHaveBeenCalledWith({
+ type: AppEvents.alertError.name,
+ payload: ['Error creating folder', 'Failed to create folder'],
+ });
+ });
+ });
+
+ it('should disable create button when form is submitting', async () => {
+ (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([
+ jest.fn(),
+ {
+ ...mockRequest,
+ isSuccess: false,
+ isError: false,
+ isLoading: true,
+ error: null,
+ },
+ ]);
+
+ setup();
+
+ // Create button should be disabled and show loading text
+ const createButton = screen.getByRole('button', { name: /creating/i });
+ expect(createButton).toBeDisabled();
+ expect(createButton).toHaveTextContent('Creating...');
+ });
+
+ it('should show PR link when PR URL is available', () => {
+ (usePullRequestParam as jest.Mock).mockReturnValue('https://github.com/grafana/grafana/pull/1234');
+
+ setup();
+
+ // PR alert should be visible - use text content instead of role
+ expect(screen.getByText('Pull request created')).toBeInTheDocument();
+ expect(screen.getByRole('link')).toHaveTextContent('https://github.com/grafana/grafana/pull/1234');
+ });
+
+ it('should call onCancel when cancel button is clicked', async () => {
+ const { user, props } = setup();
+
+ // Click cancel button
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ await user.click(cancelButton);
+
+ // Check if onCancel was called
+ expect(props.onCancel).toHaveBeenCalled();
+ });
+
+ it('should show read-only alert when repository has no workflows', () => {
+ // Mock repository with empty workflows array
+ (useRepositoryList as jest.Mock).mockReturnValue([
+ [
+ {
+ metadata: {
+ name: 'test-repo',
+ },
+ spec: {
+ type: 'github',
+ github: {
+ url: 'https://github.com/grafana/grafana',
+ branch: 'main',
+ },
+ workflows: [], // Empty workflows array
+ },
+ },
+ ],
+ false,
+ ]);
+
+ setup();
+
+ // Read-only alert should be visible
+ expect(screen.getByText('This repository is read only')).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
new file mode 100644
index 00000000000..5414e5ac9c3
--- /dev/null
+++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
@@ -0,0 +1,249 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useEffect } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { Alert, Button, Field, Input, RadioButtonGroup, Spinner, Stack, TextArea } from '@grafana/ui';
+import { useGetFolderQuery } from 'app/api/clients/folder';
+import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning';
+import { AnnoKeyManagerIdentity, AnnoKeySourcePath, Resource } from 'app/features/apiserver/types';
+import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
+import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
+import { PROVISIONING_URL } from 'app/features/provisioning/constants';
+import { usePullRequestParam, useRepositoryList } from 'app/features/provisioning/hooks';
+import { WorkflowOption } from 'app/features/provisioning/types';
+import { validateBranchName } from 'app/features/provisioning/utils/git';
+import { FolderDTO } from 'app/types';
+
+type FormData = {
+ ref?: string;
+ path: string;
+ comment?: string;
+ repo: string;
+ workflow?: WorkflowOption;
+ title: string;
+};
+
+interface Props {
+ onSubmit: () => void;
+ onCancel: () => void;
+ parentFolder?: FolderDTO;
+}
+
+const initialFormValues: Partial = {
+ title: '',
+ comment: '',
+ ref: `folder/${Date.now()}`,
+};
+
+export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) {
+ const [items, isLoading] = useRepositoryList();
+ const prURL = usePullRequestParam();
+ const navigate = useNavigate();
+ const [create, request] = useCreateRepositoryFilesWithPathMutation();
+
+ // Get k8s folder data, necessary to get parent folder path
+ const folderQuery = useGetFolderQuery(parentFolder ? { name: parentFolder.uid } : skipToken);
+ const repositoryName = folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerIdentity];
+ if (!items && !isLoading) {
+ return ;
+ }
+
+ const repository = repositoryName ? items?.find((item) => item?.metadata?.name === repositoryName) : items?.[0];
+ const repositoryConfig = repository?.spec;
+ const isGitHub = Boolean(repositoryConfig?.github);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ control,
+ setValue,
+ } = useForm({ defaultValues: { ...initialFormValues, workflow: getDefaultWorkflow(repositoryConfig) } });
+
+ const [workflow, ref] = watch(['workflow', 'ref']);
+
+ useEffect(() => {
+ setValue('workflow', getDefaultWorkflow(repositoryConfig));
+ }, [repositoryConfig, setValue]);
+
+ useEffect(() => {
+ const appEvents = getAppEvents();
+ if (request.isSuccess) {
+ onSubmit();
+
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Folder created successfully'],
+ });
+
+ const folder = request.data.resource?.upsert as Resource;
+ if (folder?.metadata?.name) {
+ navigate(`/dashboards/f/${folder?.metadata?.name}/`);
+ return;
+ }
+
+ let url = `${PROVISIONING_URL}/${repositoryName}/file/${request.data.path}`;
+ if (request.data.ref?.length) {
+ url += '?ref=' + request.data.ref;
+ }
+ navigate(url);
+ } else if (request.isError) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['Error creating folder', request.error],
+ });
+ }
+ }, [
+ request.isSuccess,
+ request.isError,
+ request.error,
+ onSubmit,
+ ref,
+ request.data,
+ workflow,
+ navigate,
+ repositoryName,
+ ]);
+
+ if (isLoading || folderQuery.isLoading) {
+ return ;
+ }
+
+ const validateFolderName = async (folderName: string) => {
+ try {
+ await validationSrv.validateNewFolderName(folderName);
+ return true;
+ } catch (e) {
+ if (e instanceof Error) {
+ return e.message;
+ }
+ return 'Invalid folder name';
+ }
+ };
+
+ const doSave = async ({ ref, title, workflow, comment }: FormData) => {
+ const repoName = repository?.metadata?.name;
+ if (!title || !repoName) {
+ return;
+ }
+ const basePath = folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? '';
+
+ // Convert folder title to filename format (lowercase, replace spaces with hyphens)
+ const titleInFilenameFormat = title
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '');
+
+ const prefix = basePath ? `${basePath}/` : '';
+ const path = `${prefix}${titleInFilenameFormat}/`;
+
+ const folderModel = {
+ title,
+ type: 'folder',
+ };
+
+ if (workflow === 'write') {
+ ref = undefined;
+ }
+
+ create({
+ ref,
+ name: repoName,
+ path,
+ message: comment || `Create folder: ${title}`,
+ body: folderModel,
+ });
+ };
+
+ return (
+
+ );
+}
+
+const BranchValidationError = () => {
+ return (
+ <>
+ Invalid branch name.
+
+ It cannot start with '/' or end with '/', '.', or whitespace.
+ It cannot contain '//' or '..'.
+ It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\', or ']'.
+ It must have at least one valid character.
+
+ >
+ );
+};
diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts
index 045be88a490..da611f9da95 100644
--- a/public/app/features/browse-dashboards/state/reducers.ts
+++ b/public/app/features/browse-dashboards/state/reducers.ts
@@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
+import { ManagerKind } from '../../apiserver/types';
import { isSharedWithMe } from '../components/utils';
import { BrowseDashboardsState } from '../types';
@@ -83,12 +84,15 @@ export function setItemSelectionState(
// SearchView doesn't use DashboardViewItemKind (yet), so we pick just the specific properties
// we're interested in
- action: PayloadAction<{ item: Pick; isSelected: boolean }>
+ action: PayloadAction<{
+ item: Pick;
+ isSelected: boolean;
+ }>
) {
const { item, isSelected } = action.payload;
// UI shouldn't allow it, but also prevent sharedwithme from being selected
- if (isSharedWithMe(item.uid)) {
+ if (isSharedWithMe(item.uid) || item.managedBy === ManagerKind.Repo) {
return;
}
@@ -169,8 +173,8 @@ export function setAllSelection(
}
for (const child of collection.items) {
- // Don't traverse into the sharedwithme folder
- if (isSharedWithMe(child.uid)) {
+ // Don't traverse into the sharedwithme/provisioned folders
+ if (isSharedWithMe(child.uid) || child.managedBy === ManagerKind.Repo) {
continue;
}
diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts
index 1d6c39dba0d..7f987fed873 100644
--- a/public/app/features/canvas/element.ts
+++ b/public/app/features/canvas/element.ts
@@ -1,7 +1,7 @@
import { ComponentType } from 'react';
import { DataLink, RegistryItem, Action } from '@grafana/data';
-import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
+import { PanelOptionsSupplier } from '@grafana/data/internal';
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
import { BackgroundConfig, Constraint, LineConfig, Placement } from 'app/plugins/panel/canvas/panelcfg.gen';
diff --git a/public/app/features/canvas/elements/button.tsx b/public/app/features/canvas/elements/button.tsx
index eb926056ace..2b70fa66e3a 100644
--- a/public/app/features/canvas/elements/button.tsx
+++ b/public/app/features/canvas/elements/button.tsx
@@ -1,8 +1,7 @@
import { css } from '@emotion/css';
import { useState } from 'react';
-import { GrafanaTheme2 } from '@grafana/data';
-import { PluginState } from '@grafana/data/src';
+import { GrafanaTheme2, PluginState } from '@grafana/data';
import { TextDimensionMode } from '@grafana/schema';
import { Button, Spinner, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
diff --git a/public/app/features/canvas/types.ts b/public/app/features/canvas/types.ts
index 1dd3f298e7a..50b926779a6 100644
--- a/public/app/features/canvas/types.ts
+++ b/public/app/features/canvas/types.ts
@@ -1,4 +1,4 @@
-import { LinkModel } from '@grafana/data/src';
+import { LinkModel } from '@grafana/data';
import { ColorDimensionConfig, ResourceDimensionConfig, TextDimensionConfig } from '@grafana/schema';
import { BackgroundImageSize } from 'app/plugins/panel/canvas/panelcfg.gen';
diff --git a/public/app/features/connections/components/ConnectionsRedirectNotice/ConnectionsRedirectNotice.tsx b/public/app/features/connections/components/ConnectionsRedirectNotice/ConnectionsRedirectNotice.tsx
index 52d2dbf3d34..d3c6e7c7109 100644
--- a/public/app/features/connections/components/ConnectionsRedirectNotice/ConnectionsRedirectNotice.tsx
+++ b/public/app/features/connections/components/ConnectionsRedirectNotice/ConnectionsRedirectNotice.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { contextSrv } from '../../../../core/core';
import { AccessControlAction } from '../../../../types';
@@ -36,8 +37,16 @@ export function ConnectionsRedirectNotice() {
Data sources have a new home! You can discover new data sources or manage existing ones in the Connections
page, accessible from the main menu.
-
- Go to connections
+
+ Go to connections
diff --git a/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx b/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx
index 2fc70dea167..3eec720cc67 100644
--- a/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx
+++ b/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Modal, Icon, Button } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { type CardGridItem } from '../CardGrid';
@@ -92,11 +93,17 @@ export function NoAccessModal({ item, isOpen, onDismiss }: NoAccessModalProps) {
Editors cannot add new connections. You may check to see if it is already configured in{' '}
Data sources .
- To add a new connection, contact your Grafana admin.
+
+
+ To add a new connection, contact your Grafana admin.
+
+
- Okay
+
+ Okay
+
diff --git a/public/app/features/connections/tabs/ConnectData/Search/Search.tsx b/public/app/features/connections/tabs/ConnectData/Search/Search.tsx
index ce5f25a538f..448abbc70dc 100644
--- a/public/app/features/connections/tabs/ConnectData/Search/Search.tsx
+++ b/public/app/features/connections/tabs/ConnectData/Search/Search.tsx
@@ -37,7 +37,7 @@ export const Search = ({ onChange, value }: Props) => {
onChange={onChange}
prefix={ }
placeholder={placeholder}
- aria-label="Search all"
+ aria-label={t('connections.search.aria-label-search-all', 'Search all')}
/>
);
diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx
index 276c71f61dd..be4fbbb8692 100644
--- a/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx
+++ b/public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx
@@ -2,7 +2,7 @@ import userEvent from '@testing-library/user-event';
import { render, screen } from 'test/test-utils';
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { config } from '@grafana/runtime';
import { SceneQueryRunner, SceneTimeRange, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx
index 582e0855008..b49a61cd1b8 100644
--- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx
+++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx
@@ -10,7 +10,7 @@ import {
standardTransformersRegistry,
toDataFrame,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { SceneCanvasText, SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import * as libpanels from 'app/features/library-panels/state/api';
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
index c9a3cd6937f..8a58e5c8917 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
@@ -6,7 +6,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { PanelProps } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import {
LocationServiceProvider,
diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx
index a8c109d261b..b5c05c830b8 100644
--- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx
+++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx
@@ -4,7 +4,7 @@ import { of } from 'rxjs';
import { render } from 'test/test-utils';
import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx
index f3c58622b27..ba6f6f48461 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx
@@ -16,7 +16,7 @@ import {
TimeRange,
toDataFrame,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime';
import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard';
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts
index fe263ee3bde..ee2bda935fd 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts
+++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts
@@ -1,7 +1,7 @@
import { of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import {
CancelActivationHandler,
CustomVariable,
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx
index 3f54a519b70..afc761b080b 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { standardEditorsRegistry, standardFieldConfigEditorRegistry } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { VizPanel } from '@grafana/scenes';
import { getAllOptionEditors, getAllStandardFieldConfigs } from 'app/core/components/OptionsUI/registry';
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx
index a63717aa9f7..9af129511f3 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx
@@ -1,3 +1,5 @@
+import { PanelPlugin } from '@grafana/data';
+import { getPanelPlugin } from '@grafana/data/test';
import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@@ -8,6 +10,15 @@ import { findVizPanelByKey } from '../utils/utils';
import { PanelOptionsPane } from './PanelOptionsPane';
import { testDashboard } from './testfiles/testDashboard';
+let pluginToLoad: PanelPlugin | undefined;
+
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getPluginImportUtils: () => ({
+ getPanelPluginFromCache: jest.fn(() => pluginToLoad),
+ }),
+}));
+
describe('PanelOptionsPane', () => {
describe('When changing plugin', () => {
it('Should set the cache', () => {
@@ -22,6 +33,38 @@ describe('PanelOptionsPane', () => {
expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig);
});
+ it('When visualization suggestion is selected should update options and fieldConfig', () => {
+ pluginToLoad = getPanelPlugin({
+ id: 'timeseries',
+ });
+
+ pluginToLoad.useFieldConfig({
+ useCustomConfig: (builder) => {
+ builder.addBooleanSwitch({
+ name: 'axisBorderShow',
+ path: 'axisBorderShow',
+ defaultValue: false,
+ });
+ },
+ });
+
+ const { optionsPane, panel } = setupTest('panel-1');
+ panel.setState({ $data: undefined });
+ panel.activate();
+
+ optionsPane.onChangePanelPlugin({
+ pluginId: 'table',
+ options: { showHeader: false },
+ fieldConfig: {
+ defaults: { custom: { axisBorderShow: true } },
+ overrides: [],
+ },
+ });
+
+ expect(panel.state.options).toEqual({ showHeader: false });
+ expect((panel.state.fieldConfig.defaults.custom as any).axisBorderShow).toEqual(true);
+ });
+
it('Should preserve correct field config', () => {
const { optionsPane, panel } = setupTest('panel-1');
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx
index d5f37b83297..bdb28d5095e 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx
@@ -72,6 +72,7 @@ export class PanelOptionsPane extends SceneObjectBase {
const panel = this.state.panelRef.resolve();
const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state;
const pluginId = options.pluginId;
+
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN,
plugin_id: pluginId,
@@ -96,6 +97,15 @@ export class PanelOptionsPane extends SceneObjectBase {
}
panel.changePluginType(pluginId, cachedOptions, newFieldConfig);
+
+ if (options.options) {
+ panel.onOptionsChange(options.options, true);
+ }
+
+ if (options.fieldConfig) {
+ panel.onFieldConfigChange(options.fieldConfig, true);
+ }
+
this.onToggleVizPicker();
};
diff --git a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx b/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx
index f96bcbfd1b4..6ce649b51b4 100644
--- a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx
+++ b/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx
@@ -1,3 +1,4 @@
+import { textUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, Icon, Stack } from '@grafana/ui';
import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning';
@@ -58,7 +59,7 @@ function DashboardPreviewBannerContent({ queryParams, slug, path }: DashboardPre
}
- onRemove={() => window.open(prParam, '_blank')}
+ onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')}
>
The value is not yet saved in the Grafana database
@@ -85,7 +86,7 @@ function DashboardPreviewBannerContent({ queryParams, slug, path }: DashboardPre
}
- onRemove={() => window.open(githubURL, '_blank')}
+ onRemove={() => window.open(textUtil.sanitizeUrl(githubURL), '_blank')}
>
The value is not yet saved in the Grafana database
diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
index 59a27eacaad..55aac56789a 100644
--- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
@@ -10,7 +10,7 @@ import {
LoadingState,
PanelData,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneDataTransformer, SceneFlexLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx
index 72a046fc6e5..db7fb59dad7 100644
--- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx
@@ -1,4 +1,4 @@
-import { sanitizeUrl } from '@grafana/data/src/text/sanitize';
+import { sanitizeUrl } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { sceneGraph } from '@grafana/scenes';
import { DashboardLink } from '@grafana/schema';
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index b2de2c14ca0..d879f1d75c8 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -108,8 +108,6 @@ export interface DashboardSceneState extends SceneObjectState {
controls?: DashboardControls;
/** True when editing */
isEditing?: boolean;
- /** Controls the visibility of hidden elements like row headers */
- showHiddenElements?: boolean;
/** True when user made a change */
isDirty?: boolean;
/** meta flags */
@@ -270,7 +268,7 @@ export class DashboardScene extends SceneObjectBase impleme
this._initialUrlState = locationService.getLocation();
// Switch to edit mode
- this.setState({ isEditing: true, showHiddenElements: true });
+ this.setState({ isEditing: true });
// Propagate change edit mode change to children
this.state.body.editModeChanged?.(true);
@@ -355,10 +353,10 @@ export class DashboardScene extends SceneObjectBase impleme
if (restoreInitialState) {
// Restore initial state and disable editing
- this.setState({ ...this._initialState, isEditing: false, showHiddenElements: false });
+ this.setState({ ...this._initialState, isEditing: false });
} else {
// Do not restore
- this.setState({ isEditing: false, showHiddenElements: false });
+ this.setState({ isEditing: false });
}
// if we are in edit panel, we need to onDiscard()
@@ -376,8 +374,6 @@ export class DashboardScene extends SceneObjectBase impleme
return this._initialState !== undefined;
}
- public onToggleHiddenElements = () => this.setState({ showHiddenElements: !this.state.showHiddenElements });
-
public pauseTrackingChanges() {
this._changeTracker.stopTrackingChanges();
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx
index 3f456078a7e..74c91e3037e 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx
@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx
index b6eb6872566..5d95b292cb4 100644
--- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx
@@ -1,7 +1,7 @@
import { of } from 'rxjs';
import { FieldType, LoadingState, PanelData, getDefaultTimeRange, toDataFrame } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { SceneCanvasText, sceneGraph, SceneGridLayout, VizPanel } from '@grafana/scenes';
import { LibraryPanel } from '@grafana/schema';
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index 6b6c0b54f50..de4951a315d 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -18,6 +18,7 @@ import {
} from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
+import grafanaConfig from 'app/core/config';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
@@ -82,6 +83,11 @@ export function ToolbarActions({ dashboard }: Props) {
const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid));
const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo);
+ // Internal only;
+ // allows viewer editing without ability to save
+ // used for grafana play
+ const canEdit = grafanaConfig.viewersCanEdit;
+
if (!isEditingPanel) {
// This adds the presence indicators in enterprise
addDynamicActions(toolbarActions, dynamicDashNavActions.left, 'left-actions');
@@ -347,7 +353,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'main-buttons',
- condition: !isEditing && dashboard.canEditDashboard() && !isViewingPanel && !isPlaying && editable,
+ condition: !isEditing && (dashboard.canEditDashboard() || canEdit) && !isViewingPanel && !isPlaying && editable,
render: () => (
{
diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
index d0aa2f56b9f..182ebac330f 100644
--- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
@@ -8,7 +8,7 @@ import {
toDataFrame,
urlUtil,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
import {
LocalValueVariable,
diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx
index ec13a7992c8..00458d505a9 100644
--- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx
@@ -1,4 +1,4 @@
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
index 843f58b5640..28effcf18b5 100644
--- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
@@ -1,5 +1,5 @@
import { VariableRefresh } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import {
SceneCanvasText,
diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx
index 2e36f85e108..f2b8a6bcadc 100644
--- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx
@@ -10,24 +10,23 @@ export interface ResponsiveGridItemProps extends SceneComponentProps
{model.state.repeatedPanels.map((item) => (
-
+
))}
>
) : (
-
+
);
diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
index 0885e481c69..5379a8c3305 100644
--- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
@@ -1,4 +1,5 @@
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
+import { GRID_CELL_VMARGIN } from 'app/core/constants';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -20,8 +21,19 @@ import { getEditOptions } from './ResponsiveGridLayoutManagerEditor';
interface ResponsiveGridLayoutManagerState extends SceneObjectState {
layout: ResponsiveGridLayout;
+ maxColumnCount: number;
+ rowHeight: AutoGridRowHeight;
+ columnWidth: AutoGridColumnWidth;
+ fillScreen: boolean;
}
+export type AutoGridColumnWidth = 'narrow' | 'standard' | 'wide' | 'custom' | number;
+export type AutoGridRowHeight = 'short' | 'standard' | 'tall' | 'custom' | number;
+
+export const AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT = 3;
+export const AUTO_GRID_DEFAULT_COLUMN_WIDTH = 'standard';
+export const AUTO_GRID_DEFAULT_ROW_HEIGHT = 'standard';
+
export class ResponsiveGridLayoutManager
extends SceneObjectBase
implements DashboardLayoutManager
@@ -45,10 +57,30 @@ export class ResponsiveGridLayoutManager
public readonly descriptor = ResponsiveGridLayoutManager.descriptor;
- public static defaultCSS = {
- templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
- autoRows: 'minmax(300px, auto)',
- };
+ public constructor(state: Partial) {
+ const maxColumnCount = state.maxColumnCount ?? AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT;
+ const columnWidth = state.columnWidth ?? AUTO_GRID_DEFAULT_COLUMN_WIDTH;
+ const rowHeight = state.rowHeight ?? AUTO_GRID_DEFAULT_ROW_HEIGHT;
+ const fillScreen = state.fillScreen ?? false;
+
+ super({
+ ...state,
+ maxColumnCount,
+ columnWidth,
+ rowHeight,
+ fillScreen,
+ layout:
+ state.layout ??
+ new ResponsiveGridLayout({
+ templateColumns: getTemplateColumnsTemplate(maxColumnCount, columnWidth),
+ autoRows: getAutoRowsTemplate(rowHeight, fillScreen),
+ }),
+ });
+
+ // @ts-ignore
+ this.state.layout.getDragClassCancel = () => 'drag-cancel';
+ this.state.layout.isDraggable = () => true;
+ }
public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this);
@@ -159,24 +191,46 @@ export class ResponsiveGridLayoutManager
return getEditOptions(this);
}
- public changeColumns(columns: string) {
- this.state.layout.setState({ templateColumns: columns });
+ public onMaxColumnCountChanged(maxColumnCount: number) {
+ this.setState({ maxColumnCount: maxColumnCount });
+ this.state.layout.setState({
+ templateColumns: getTemplateColumnsTemplate(maxColumnCount, this.state.columnWidth),
+ });
}
- public changeRows(rows: string) {
- this.state.layout.setState({ autoRows: rows });
+ public onColumnWidthChanged(columnWidth: AutoGridColumnWidth) {
+ if (columnWidth === 'custom') {
+ columnWidth = getNamedColumWidthInPixels(this.state.columnWidth);
+ }
+
+ this.setState({ columnWidth: columnWidth });
+ this.state.layout.setState({
+ templateColumns: getTemplateColumnsTemplate(this.state.maxColumnCount, this.state.columnWidth),
+ });
}
- public static createEmpty(): ResponsiveGridLayoutManager {
- return new ResponsiveGridLayoutManager({
- layout: new ResponsiveGridLayout({
- children: [],
- templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns,
- autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows,
- }),
+ public onFillScreenChanged(fillScreen: boolean) {
+ this.setState({ fillScreen });
+ this.state.layout.setState({
+ autoRows: getAutoRowsTemplate(this.state.rowHeight, fillScreen),
});
}
+ public onRowHeightChanged(rowHeight: AutoGridRowHeight) {
+ if (rowHeight === 'custom') {
+ rowHeight = getNamedHeightInPixels(this.state.rowHeight);
+ }
+
+ this.setState({ rowHeight });
+ this.state.layout.setState({
+ autoRows: getAutoRowsTemplate(rowHeight, this.state.fillScreen),
+ });
+ }
+
+ public static createEmpty(): ResponsiveGridLayoutManager {
+ return new ResponsiveGridLayoutManager({});
+ }
+
public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager {
const panels = layout.getVizPanels();
const children: ResponsiveGridItem[] = [];
@@ -195,3 +249,47 @@ export class ResponsiveGridLayoutManager
function ResponsiveGridLayoutManagerRenderer({ model }: SceneComponentProps) {
return ;
}
+
+export function getTemplateColumnsTemplate(maxColumnCount: number, columnWidth: AutoGridColumnWidth) {
+ return `repeat(auto-fit, minmax(min(max(100% / ${maxColumnCount} - ${GRID_CELL_VMARGIN}px, ${getNamedColumWidthInPixels(columnWidth)}px), 100%), 1fr))`;
+}
+
+function getNamedColumWidthInPixels(columnWidth: AutoGridColumnWidth) {
+ if (typeof columnWidth === 'number') {
+ return columnWidth;
+ }
+
+ switch (columnWidth) {
+ case 'narrow':
+ return 192;
+ case 'wide':
+ return 768;
+ case 'custom':
+ case 'standard':
+ default:
+ return 448;
+ }
+}
+
+function getNamedHeightInPixels(rowHeight: AutoGridRowHeight) {
+ if (typeof rowHeight === 'number') {
+ return rowHeight;
+ }
+
+ switch (rowHeight) {
+ case 'short':
+ return 128;
+ case 'tall':
+ return 512;
+ case 'custom':
+ case 'standard':
+ default:
+ return 320;
+ }
+}
+
+export function getAutoRowsTemplate(rowHeight: AutoGridRowHeight, fillScreen: boolean) {
+ const rowHeightPixels = getNamedHeightInPixels(rowHeight);
+ const maxRowHeightValue = fillScreen ? 'auto' : `${rowHeightPixels}px`;
+ return `minmax(${rowHeightPixels}px, ${maxRowHeightValue})`;
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx
index 94202f1a200..84fde95cf4c 100644
--- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx
@@ -1,25 +1,27 @@
-import { SelectableValue } from '@grafana/data';
-import { Select } from '@grafana/ui';
+import { capitalize } from 'lodash';
+import React, { useEffect } from 'react';
+
+import { Button, Combobox, ComboboxOption, Field, InlineSwitch, Input, Stack } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
-import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
-
-const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
+import { AutoGridColumnWidth, AutoGridRowHeight, ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
options.push(
new OptionsPaneItemDescriptor({
- title: t('dashboard.responsive-layout.options.columns', 'Columns'),
+ title: 'Column options',
+ skipField: true,
render: () => ,
})
);
options.push(
new OptionsPaneItemDescriptor({
- title: t('dashboard.responsive-layout.options.rows', 'Rows'),
+ title: 'Row height options',
+ skipField: true,
render: () => ,
})
);
@@ -28,56 +30,210 @@ export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): Opti
}
function GridLayoutColumns({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
- const { templateColumns } = layoutManager.state.layout.useState();
+ const { maxColumnCount, columnWidth } = layoutManager.useState();
+ const [inputRef, setInputRef] = React.useState(null);
+ const [focusInput, setFocusInput] = React.useState(false);
+ const [customMinWidthError, setCustomMinWidthError] = React.useState(false);
+
+ useEffect(() => {
+ if (focusInput && inputRef) {
+ inputRef.focus();
+ setFocusInput(false);
+ }
+ }, [focusInput, inputRef]);
+
+ const minWidthOptions: Array> = [
+ 'narrow' as const,
+ 'standard' as const,
+ 'wide' as const,
+ 'custom' as const,
+ ].map((value) => ({
+ label: capitalize(value),
+ value,
+ }));
+
+ const isStandardMinWidth = typeof columnWidth === 'string';
+
+ const minWidthLabel = isStandardMinWidth
+ ? t('dashboard.responsive-layout.options.min-width', 'Min column width')
+ : t('dashboard.responsive-layout.options.min-width-custom', 'Custom min width');
+ const colOptions = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].map((value) => ({ label: value, value }));
+
+ const onCustomMinWidthChanged = (e: React.ChangeEvent) => {
+ const pixels = parseInt(e.target.value, 10);
+ if (isNaN(pixels) || pixels < 50 || pixels > 2000) {
+ setCustomMinWidthError(true);
+ return;
+ } else if (customMinWidthError) {
+ setCustomMinWidthError(false);
+ }
- const colOptions: Array> = [
- { label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
- { label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
- { label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
- ];
+ layoutManager.onColumnWidthChanged(pixels);
+ };
- for (const size of sizes) {
- colOptions.push({
- label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
- value: `repeat(auto-fit, minmax(${size}px, auto))`,
- });
- }
+ const onNamedMinWidthChanged = (value: ComboboxOption) => {
+ if (value.value === 'custom') {
+ setFocusInput(true);
+ }
+ layoutManager.onColumnWidthChanged(value.value);
+ };
+
+ const onClearCustomMinWidth = () => {
+ if (customMinWidthError) {
+ setCustomMinWidthError(false);
+ }
+
+ layoutManager.onColumnWidthChanged('standard');
+ };
return (
- layoutManager.changeColumns(value!)}
- allowCustomValue={true}
- />
+
+
+ {isStandardMinWidth ? (
+
+ ) : (
+ setInputRef(ref)}
+ type="number"
+ min={50}
+ max={2000}
+ invalid={customMinWidthError}
+ suffix={
+
+ {t('dashboard.responsive-layout.options.custom-min-width.clear', 'Clear')}
+
+ }
+ />
+ )}
+
+
+ layoutManager.onMaxColumnCountChanged(parseInt(value, 10))}
+ />
+
+
);
}
function GridLayoutRows({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
- const { autoRows } = layoutManager.state.layout.useState();
+ const { rowHeight, fillScreen } = layoutManager.useState();
+ const [inputRef, setInputRef] = React.useState(null);
+ const [focusInput, setFocusInput] = React.useState(false);
+ const [customMinWidthError, setCustomMinWidthError] = React.useState(false);
+
+ useEffect(() => {
+ if (focusInput && inputRef) {
+ inputRef.focus();
+ setFocusInput(false);
+ }
+ }, [focusInput, inputRef]);
+
+ const minWidthOptions: Array> = [
+ 'short' as const,
+ 'standard' as const,
+ 'tall' as const,
+ 'custom' as const,
+ ].map((value) => ({
+ label: capitalize(value),
+ value,
+ }));
+
+ const isStandardHeight = typeof rowHeight === 'string';
+ const rowHeightLabel = rowHeight
+ ? t('dashboard.responsive-layout.options.min-height', 'Row height')
+ : t('dashboard.responsive-layout.options.min-height-custom', 'Custom row height');
+
+ const onCustomHeightChanged = (e: React.ChangeEvent) => {
+ const pixels = parseInt(e.target.value, 10);
+ if (isNaN(pixels) || pixels < 50 || pixels > 2000) {
+ setCustomMinWidthError(true);
+ return;
+ } else if (customMinWidthError) {
+ setCustomMinWidthError(false);
+ }
+
+ layoutManager.onRowHeightChanged(pixels);
+ };
- const rowOptions: Array> = [];
+ const onNamedMinHeightChanged = (value: ComboboxOption) => {
+ if (value.value === 'custom') {
+ setFocusInput(true);
+ }
+ layoutManager.onRowHeightChanged(value.value);
+ };
- for (const size of sizes) {
- rowOptions.push({
- label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
- value: `minmax(${size}px, auto)`,
- });
- }
+ const onClearCustomRowHeight = () => {
+ if (customMinWidthError) {
+ setCustomMinWidthError(false);
+ }
- for (const size of sizes) {
- rowOptions.push({
- label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
- value: `${size}px`,
- });
- }
+ layoutManager.onRowHeightChanged('standard');
+ };
return (
- layoutManager.changeRows(value!)}
- allowCustomValue={true}
- />
+
+
+ {isStandardHeight ? (
+
+ ) : (
+ setInputRef(ref)}
+ width={18}
+ type="number"
+ min={50}
+ max={2000}
+ invalid={customMinWidthError}
+ suffix={
+
+ {t('dashboard.responsive-layout.options.custom-min-height.clear', 'Clear')}
+
+ }
+ />
+ )}
+
+
+ layoutManager.onFillScreenChanged(!fillScreen)} />
+
+
);
}
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
index 8d3b8f91a75..8bdc2ad2163 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
@@ -1,5 +1,6 @@
import { sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
+import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
@@ -22,7 +23,7 @@ export interface RowItemState extends SceneObjectState {
title?: string;
isCollapsed?: boolean;
isHeaderHidden?: boolean;
- height?: 'expand' | 'min';
+ fillScreen?: boolean;
conditionalRendering?: ConditionalRendering;
}
@@ -72,6 +73,10 @@ export class RowItem
return this.state.layout;
}
+ public getSlug(): string {
+ return kbn.slugifyForUrl(sceneGraph.interpolate(this, this.state.title ?? 'Row'));
+ }
+
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout: this._layoutRestorer.getLayout(layout, this.state.layout) });
}
@@ -136,8 +141,8 @@ export class RowItem
this.setState({ isHeaderHidden });
}
- public onChangeHeight(height: 'expand' | 'min') {
- this.setState({ height });
+ public onChangeFillScreen(fillScreen: boolean) {
+ this.setState({ fillScreen });
}
public onChangeRepeat(repeat: string | undefined) {
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx
index 4bfc3b77544..1abade8a396 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx
@@ -1,8 +1,7 @@
import { useMemo } from 'react';
-import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
-import { Alert, Input, RadioButtonGroup, Switch, TextLink } from '@grafana/ui';
+import { Alert, Input, Switch, TextLink } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -31,8 +30,8 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
)
.addItem(
new OptionsPaneItemDescriptor({
- title: t('dashboard.rows-layout.row-options.row.height', 'Height'),
- render: () => ,
+ title: t('dashboard.rows-layout.row-options.row.fill-screen', 'Fill screen'),
+ render: () => ,
})
)
.addItem(
@@ -99,15 +98,10 @@ function RowHeaderSwitch({ row }: { row: RowItem }) {
return row.onHeaderHiddenToggle()} />;
}
-function RowHeightSelect({ row }: { row: RowItem }) {
- const { height = 'min' } = row.useState();
+function FillScreenSwitch({ row }: { row: RowItem }) {
+ const { fillScreen } = row.useState();
- const options: Array> = [
- { label: t('dashboard.rows-layout.options.height-expand', 'Expand'), value: 'expand' },
- { label: t('dashboard.rows-layout.options.height-min', 'Min'), value: 'min' },
- ];
-
- return row.onChangeHeight(option)} />;
+ return row.onChangeFillScreen(!fillScreen)} />;
}
function RowRepeatSelect({ row }: { row: RowItem }) {
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemMenu.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemMenu.tsx
index 1243c07d731..0782595ba45 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemMenu.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemMenu.tsx
@@ -1,4 +1,4 @@
-import { css } from '@emotion/css';
+import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Dropdown, Menu, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
@@ -14,7 +14,7 @@ export function RowItemMenu({ model }: RowItemMenuProps) {
const styles = useStyles2(getStyles);
return (
-
+
(
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx
index e69e6d8d407..4e6eb2df022 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx
@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps } from '@grafana/scenes';
-import { clearButtonStyles, Icon, useStyles2 } from '@grafana/ui';
+import { clearButtonStyles, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { useIsClone } from '../../utils/clone';
@@ -19,25 +19,24 @@ import { RowItem } from './RowItem';
import { RowItemMenu } from './RowItemMenu';
export function RowItemRenderer({ model }: SceneComponentProps) {
- const { layout, isCollapsed, height = 'min', isHeaderHidden } = model.useState();
+ const { layout, isCollapsed, fillScreen, isHeaderHidden } = model.useState();
const isClone = useIsClone(model);
- const { isEditing, showHiddenElements } = useDashboardState(model);
+ const { isEditing } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model);
const { isSelected, onSelect, isSelectable } = useElementSelectionScene(model);
const title = useInterpolatedTitle(model);
const styles = useStyles2(getStyles);
const clearStyles = useStyles2(clearButtonStyles);
- const shouldGrow = !isCollapsed && height === 'expand';
- const isHiddenButVisibleElement = showHiddenElements && isConditionallyHidden;
- const isHiddenButVisibleHeader = showHiddenElements && isHeaderHidden;
+ const shouldGrow = !isCollapsed && fillScreen;
+ const isHidden = isConditionallyHidden && !isEditing;
// Highlight the full row when hovering over header
const [selectableHighlight, setSelectableHighlight] = useState(false);
const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []);
const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []);
- if (isConditionallyHidden && !showHiddenElements) {
+ if (isHidden) {
return null;
}
@@ -49,19 +48,24 @@ export function RowItemRenderer({ model }: SceneComponentProps) {
isEditing && isCollapsed && styles.wrapperEditingCollapsed,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
- isHiddenButVisibleElement && 'dashboard-visible-hidden-element',
+ isConditionallyHidden && 'dashboard-visible-hidden-element',
!isClone && isSelected && 'dashboard-selected-element',
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element'
)}
- onPointerDown={onSelect}
+ onPointerDown={(e) => {
+ // If we selected and are clicking a button inside row header then don't de-select row
+ if (isSelected && e.target instanceof Element && e.target.closest('button')) {
+ // Stop propagation otherwise dashboaed level onPointerDown will de-select row
+ e.stopPropagation();
+ return;
+ }
+
+ onSelect?.(e);
+ }}
>
- {(!isHeaderHidden || (isEditing && showHiddenElements)) && (
+ {(!isHeaderHidden || isEditing) && (
@@ -76,8 +80,15 @@ export function RowItemRenderer({ model }: SceneComponentProps
) {
data-testid={selectors.components.DashboardRow.title(title!)}
>
-
+
{title}
+ {isHeaderHidden && (
+
+
+
+ )}
{!isClone && isEditing && }
@@ -108,6 +119,9 @@ function getStyles(theme: GrafanaTheme2) {
gap: theme.spacing(1),
}),
rowTitle: css({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(2),
fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
@@ -117,6 +131,9 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1,
minWidth: 0,
}),
+ rowTitleHidden: css({
+ textDecoration: 'line-through',
+ }),
wrapper: css({
display: 'flex',
flexDirection: 'column',
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
index ec5b4710089..ca967ce3d06 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
@@ -1,5 +1,5 @@
import { VariableRefresh } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import {
SceneGridRow,
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
index df09763b00b..6bef7c0bf8d 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
@@ -1,5 +1,6 @@
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
+import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { getDefaultVizPanel } from '../../utils/utils';
@@ -53,6 +54,10 @@ export class TabItem
return this.state.layout;
}
+ public getSlug(): string {
+ return kbn.slugifyForUrl(sceneGraph.interpolate(this, this.state.title ?? 'Tab'));
+ }
+
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout: this._layoutRestorer.getLayout(layout, this.state.layout) });
}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
index e3b7d66afdd..f0b5c1080ed 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
@@ -13,10 +13,12 @@ export function TabItemRenderer({ model }: SceneComponentProps) {
const { tabs, currentTabIndex } = parentLayout.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const { isSelected, onSelect, isSelectable } = useElementSelection(key);
+ const mySlug = model.getSlug();
+ const urlKey = parentLayout.getUrlKey();
const myIndex = tabs.findIndex((tab) => tab === model);
const isActive = myIndex === currentTabIndex;
const location = useLocation();
- const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { tab: myIndex }));
+ const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug }));
return (
{
+ describe('url sync', () => {
+ it('when on top level', () => {
+ const tabsLayoutManager = new TabsLayoutManager({
+ tabs: [new TabItem({ title: 'Performance' })],
+ });
+
+ const urlState = tabsLayoutManager.getUrlState();
+ expect(urlState).toEqual({ dtab: 'performance' });
+ });
+
+ it('when nested under row and parent tab', () => {
+ const innerMostTabs = new TabsLayoutManager({
+ tabs: [new TabItem({ title: 'Performance' })],
+ });
+
+ new RowsLayoutManager({
+ rows: [
+ new RowItem({
+ title: 'Overview',
+ layout: new TabsLayoutManager({
+ tabs: [
+ new TabItem({
+ title: 'Frontend',
+ layout: innerMostTabs,
+ }),
+ ],
+ }),
+ }),
+ ],
+ });
+
+ const urlState = innerMostTabs.getUrlState();
+ expect(urlState).toEqual({
+ ['overview-frontend-dtab']: 'performance',
+ });
+ });
+ });
+});
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
index 71af05bfce7..f4369290323 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
@@ -12,6 +12,7 @@ import {
ObjectRemovedFromCanvasEvent,
ObjectsReorderedOnCanvasEvent,
} from '../../edit-pane/shared';
+import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -44,7 +45,7 @@ export class TabsLayoutManager extends SceneObjectBase i
public readonly descriptor = TabsLayoutManager.descriptor;
- protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] });
+ protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: () => [this.getUrlKey()] });
public constructor(state: Partial) {
super({
@@ -65,19 +66,23 @@ export class TabsLayoutManager extends SceneObjectBase i
}
public getUrlState() {
- return { tab: this.state.currentTabIndex.toString() };
+ const key = this.getUrlKey();
+ return { [key]: this.getCurrentTab().getSlug() };
}
public updateFromUrl(values: SceneObjectUrlValues) {
- if (!values.tab) {
+ const key = this.getUrlKey();
+ const urlValue = values[key];
+
+ if (!urlValue) {
return;
}
- if (typeof values.tab === 'string') {
- const tabIndex = parseInt(values.tab, 10);
- if (this.state.tabs[tabIndex]) {
- this.setState({ currentTabIndex: tabIndex });
- } else {
- this.setState({ currentTabIndex: 0 });
+
+ if (typeof values[key] === 'string') {
+ // find tab with matching slug
+ const matchIndex = this.state.tabs.findIndex((tab) => tab.getSlug() === urlValue);
+ if (matchIndex !== -1) {
+ this.setState({ currentTabIndex: matchIndex });
}
}
}
@@ -212,4 +217,24 @@ export class TabsLayoutManager extends SceneObjectBase i
return new TabsLayoutManager({ tabs });
}
+
+ getUrlKey(): string {
+ let parent = this.parent;
+ // Panel edit uses tab key already so we are using dtab here to not conflict
+ let key = 'dtab';
+
+ while (parent) {
+ if (parent instanceof TabItem) {
+ key = `${parent.getSlug()}-${key}`;
+ }
+
+ if (parent instanceof RowItem) {
+ key = `${parent.getSlug()}-${key}`;
+ }
+
+ parent = parent.parent;
+ }
+
+ return key;
+ }
}
diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts
index cda96b97e9c..a9f63b34cf0 100644
--- a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts
+++ b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts
@@ -1,5 +1,5 @@
import { PanelTypeChangedHandler } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getAngularPanelMigrationHandler } from './angularMigration';
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts
index 72a04194f82..9159ca0943b 100644
--- a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts
+++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts
@@ -2,7 +2,16 @@ import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/d
import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem';
import { ResponsiveGridLayout } from '../../scene/layout-responsive-grid/ResponsiveGridLayout';
-import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
+import {
+ AUTO_GRID_DEFAULT_COLUMN_WIDTH,
+ AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT,
+ AUTO_GRID_DEFAULT_ROW_HEIGHT,
+ AutoGridColumnWidth,
+ AutoGridRowHeight,
+ getAutoRowsTemplate,
+ getTemplateColumnsTemplate,
+ ResponsiveGridLayoutManager,
+} from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getGridItemKeyForPanelId } from '../../utils/utils';
@@ -14,10 +23,10 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
return {
kind: 'ResponsiveGridLayout',
spec: {
- col:
- layoutManager.state.layout.state.templateColumns?.toString() ??
- ResponsiveGridLayoutManager.defaultCSS.templateColumns,
- row: layoutManager.state.layout.state.autoRows?.toString() ?? ResponsiveGridLayoutManager.defaultCSS.autoRows,
+ maxColumnCount: layoutManager.state.maxColumnCount,
+ fillScreen: layoutManager.state.fillScreen,
+ ...serializeAutoGridColumnWidth(layoutManager.state.columnWidth),
+ ...serializeAutoGridRowHeight(layoutManager.state.rowHeight),
items: layoutManager.state.layout.state.children.map((child) => {
if (!(child instanceof ResponsiveGridItem)) {
throw new Error('Expected ResponsiveGridItem');
@@ -73,11 +82,35 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
});
return new ResponsiveGridLayoutManager({
+ maxColumnCount: layout.spec.maxColumnCount,
+ columnWidth: layout.spec.columnWidthMode === 'custom' ? layout.spec.columnWidth : layout.spec.columnWidthMode,
+ rowHeight: layout.spec.rowHeightMode === 'custom' ? layout.spec.rowHeight : layout.spec.rowHeightMode,
+ fillScreen: layout.spec.fillScreen,
layout: new ResponsiveGridLayout({
- templateColumns: layout.spec.col,
- autoRows: layout.spec.row,
+ templateColumns: getTemplateColumnsTemplate(
+ layout.spec.maxColumnCount ?? AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT,
+ layout.spec.columnWidth ?? AUTO_GRID_DEFAULT_COLUMN_WIDTH
+ ),
+ autoRows: getAutoRowsTemplate(
+ layout.spec.rowHeight ?? AUTO_GRID_DEFAULT_ROW_HEIGHT,
+ layout.spec.fillScreen ?? false
+ ),
children,
}),
});
}
}
+
+function serializeAutoGridColumnWidth(columnWidth: AutoGridColumnWidth) {
+ return {
+ columnWidthMode: typeof columnWidth === 'number' ? 'custom' : columnWidth,
+ columnWidth: typeof columnWidth === 'number' ? columnWidth : undefined,
+ };
+}
+
+function serializeAutoGridRowHeight(rowHeight: AutoGridRowHeight) {
+ return {
+ rowHeightMode: typeof rowHeight === 'number' ? 'custom' : rowHeight,
+ rowHeight: typeof rowHeight === 'number' ? rowHeight : undefined,
+ };
+}
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts
index 4d5429e1b34..82d24caefff 100644
--- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts
+++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts
@@ -46,8 +46,9 @@ describe('deserialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
- row: 'minmax(min-content, max-content)',
- col: 'repeat(auto-fit, minmax(400px, 1fr))',
+ columnWidthMode: 'standard',
+ rowHeightMode: 'standard',
+ maxColumnCount: 4,
items: [],
},
},
@@ -75,8 +76,9 @@ describe('deserialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
- row: 'minmax(min-content, max-content)',
- col: 'repeat(auto-fit, minmax(400px, 1fr))',
+ columnWidthMode: 'standard',
+ rowHeightMode: 'standard',
+ maxColumnCount: 4,
items: [],
},
},
@@ -233,11 +235,10 @@ describe('serialization', () => {
title: 'Row 1',
isCollapsed: false,
layout: new ResponsiveGridLayoutManager({
- layout: new ResponsiveGridLayout({
- children: [],
- templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
- autoRows: 'minmax(min-content, max-content)',
- }),
+ columnWidth: 'standard',
+ rowHeight: 'standard',
+ maxColumnCount: 4,
+ layout: new ResponsiveGridLayout({}),
}),
}),
new RowItem({
@@ -269,8 +270,12 @@ describe('serialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
- row: 'minmax(min-content, max-content)',
- col: 'repeat(auto-fit, minmax(400px, 1fr))',
+ columnWidth: undefined,
+ rowHeight: undefined,
+ fillScreen: false,
+ rowHeightMode: 'standard',
+ columnWidthMode: 'standard',
+ maxColumnCount: 4,
items: [],
},
},
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts
index e1e0fc7901e..35214720bce 100644
--- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts
+++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts
@@ -28,7 +28,13 @@ describe('deserialization', () => {
tabs: [
{
kind: 'TabsLayoutTab',
- spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } },
+ spec: {
+ title: 'Tab 1',
+ layout: {
+ kind: 'ResponsiveGridLayout',
+ spec: { columnWidthMode: 'standard', rowHeightMode: 'standard', maxColumnCount: 4, items: [] },
+ },
+ },
},
],
},
@@ -64,7 +70,13 @@ describe('deserialization', () => {
tabs: [
{
kind: 'TabsLayoutTab',
- spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } },
+ spec: {
+ title: 'Tab 1',
+ layout: {
+ kind: 'ResponsiveGridLayout',
+ spec: { columnWidthMode: 'standard', rowHeightMode: 'standard', maxColumnCount: 4, items: [] },
+ },
+ },
},
{ kind: 'TabsLayoutTab', spec: { title: 'Tab 2', layout: { kind: 'GridLayout', spec: { items: [] } } } },
],
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
index e1bd8b6fe0f..fd16165b8bd 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
@@ -507,8 +507,10 @@ describe('transformSaveModelSchemaV2ToScene', () => {
dashboard.spec.layout = {
kind: 'ResponsiveGridLayout',
spec: {
- col: 'colString',
- row: 'rowString',
+ maxColumnCount: 4,
+ columnWidthMode: 'custom',
+ columnWidth: 100,
+ rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@@ -525,8 +527,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const scene = transformSaveModelSchemaV2ToScene(dashboard);
const layoutManager = scene.state.body as ResponsiveGridLayoutManager;
expect(layoutManager.descriptor.kind).toBe('ResponsiveGridLayout');
- expect(layoutManager.state.layout.state.templateColumns).toBe('colString');
- expect(layoutManager.state.layout.state.autoRows).toBe('rowString');
+ expect(layoutManager.state.maxColumnCount).toBe(4);
+ expect(layoutManager.state.columnWidth).toBe(100);
+ expect(layoutManager.state.rowHeight).toBe('standard');
expect(layoutManager.state.layout.state.children.length).toBe(1);
const gridItem = layoutManager.state.layout.state.children[0] as ResponsiveGridItem;
expect(gridItem.state.body.state.key).toBe('panel-1');
@@ -545,8 +548,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
- col: 'colString',
- row: 'rowString',
+ maxColumnCount: 4,
+ columnWidthMode: 'standard',
+ rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@@ -571,8 +575,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(layoutManager.state.tabs.length).toBe(1);
expect(layoutManager.state.tabs[0].state.title).toBe('tab1');
const gridLayoutManager = layoutManager.state.tabs[0].state.layout as ResponsiveGridLayoutManager;
- expect(gridLayoutManager.state.layout.state.templateColumns).toBe('colString');
- expect(gridLayoutManager.state.layout.state.autoRows).toBe('rowString');
+ expect(gridLayoutManager.state.maxColumnCount).toBe(4);
+ expect(gridLayoutManager.state.columnWidth).toBe('standard');
+ expect(gridLayoutManager.state.rowHeight).toBe('standard');
expect(gridLayoutManager.state.layout.state.children.length).toBe(1);
const gridItem = gridLayoutManager.state.layout.state.children[0] as ResponsiveGridItem;
expect(gridItem.state.body.state.key).toBe('panel-1');
@@ -592,8 +597,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
- col: 'colString',
- row: 'rowString',
+ maxColumnCount: 4,
+ columnWidthMode: 'standard',
+ rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@@ -645,6 +651,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(layoutManager.state.rows.length).toBe(2);
const row1Manager = layoutManager.state.rows[0].state.layout as ResponsiveGridLayoutManager;
expect(row1Manager.descriptor.kind).toBe('ResponsiveGridLayout');
+ expect(row1Manager.state.maxColumnCount).toBe(4);
+ expect(row1Manager.state.columnWidth).toBe('standard');
+ expect(row1Manager.state.rowHeight).toBe('standard');
const row1GridItem = row1Manager.state.layout.state.children[0] as ResponsiveGridItem;
expect(row1GridItem.state.body.state.key).toBe('panel-1');
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
index e74c3f21664..546c8e95038 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
@@ -1,5 +1,5 @@
import { LoadingState } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { config } from '@grafana/runtime';
import {
AdHocFiltersVariable,
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
index 2584d091907..0c139f9fc11 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
@@ -176,7 +176,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
let variables: SceneVariableSet | undefined;
let annotationLayers: SceneDataLayerProvider[] = [];
let alertStatesLayer: AlertStatesDataLayer | undefined;
- const uid = dto.uid;
+ const uid = oldModel.uid;
const serializerVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1';
if (oldModel.templating?.list?.length) {
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
index 9d6061e3c6f..f1ed038dfb5 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
@@ -13,7 +13,7 @@ import {
toDataFrame,
VariableSupportType,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
import { MultiValueVariable, sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes';
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
index ff1df0ded25..403432cf8ba 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
@@ -631,9 +631,11 @@ describe('dynamic layouts', () => {
const scene = setupDashboardScene(
getMinimalSceneState(
new ResponsiveGridLayoutManager({
+ columnWidth: 100,
+ rowHeight: 'standard',
+ maxColumnCount: 4,
+ fillScreen: true,
layout: new ResponsiveGridLayout({
- autoRows: 'rowString',
- templateColumns: 'colString',
children: [
new ResponsiveGridItem({
body: new VizPanel({}),
@@ -649,8 +651,12 @@ describe('dynamic layouts', () => {
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('ResponsiveGridLayout');
const respGridLayout = result.layout.spec as ResponsiveGridLayoutSpec;
- expect(respGridLayout.col).toBe('colString');
- expect(respGridLayout.row).toBe('rowString');
+ expect(respGridLayout.columnWidthMode).toBe('custom');
+ expect(respGridLayout.columnWidth).toBe(100);
+ expect(respGridLayout.rowHeightMode).toBe('standard');
+ expect(respGridLayout.rowHeight).toBeUndefined();
+ expect(respGridLayout.maxColumnCount).toBe(4);
+ expect(respGridLayout.fillScreen).toBe(true);
expect(respGridLayout.items.length).toBe(2);
expect(respGridLayout.items[0].kind).toBe('ResponsiveGridLayoutItem');
});
diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx
index f3d93596d03..8c5f690518e 100644
--- a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx
+++ b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx
@@ -8,7 +8,7 @@ import {
getDefaultTimeRange,
toDataFrame,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import {
SceneVariableSet,
diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
index e6449d0d3f6..c2d238354c0 100644
--- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
+++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
@@ -1,3 +1,4 @@
+import { config } from '@grafana/runtime';
import { SceneTimeRange } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
@@ -108,6 +109,92 @@ describe('VersionsEditView', () => {
expect(versionsView.state.isNewLatest).toBe(true);
});
+
+ it('should correctly identify last page when partial page is returned without version 1', async () => {
+ jest.mocked(historySrv.getHistoryList).mockResolvedValueOnce({
+ continueToken: '',
+ versions: [
+ {
+ id: 4,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 3,
+ restoredFrom: 0,
+ version: 4,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ checked: false,
+ },
+ {
+ id: 3,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 1,
+ restoredFrom: 1,
+ version: 3,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ checked: false,
+ },
+ ],
+ });
+
+ versionsView.reset();
+ versionsView.fetchVersions();
+ await new Promise(process.nextTick);
+
+ expect(versionsView.versions.length).toBeLessThan(VERSIONS_FETCH_LIMIT);
+ expect(versionsView.versions.find((rev) => rev.version === 1)).toBeUndefined();
+ });
+
+ it('should correctly identify last page when kubernetesClientDashboardsFolders is enabled and continueToken is empty', async () => {
+ // @ts-ignore
+ config.featureToggles.kubernetesClientDashboardsFolders = true;
+
+ jest.mocked(historySrv.getHistoryList).mockResolvedValueOnce({
+ continueToken: '',
+ versions: [
+ {
+ id: 4,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 3,
+ restoredFrom: 0,
+ version: 4,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ checked: false,
+ },
+ {
+ id: 3,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 1,
+ restoredFrom: 1,
+ version: 3,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ checked: false,
+ },
+ ],
+ });
+
+ versionsView.reset();
+ versionsView.fetchVersions();
+ await new Promise(process.nextTick);
+
+ expect(versionsView.versions.length).toBeLessThan(VERSIONS_FETCH_LIMIT);
+ expect(versionsView.versions.find((rev) => rev.version === 1)).toBeUndefined();
+ expect(versionsView.continueToken).toBe('');
+
+ // reset feature flag
+ // @ts-ignore
+ config.featureToggles.kubernetesClientDashboardsFolders = false;
+ });
});
});
diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
index 96431929a4c..6eb427966cf 100644
--- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
+++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
@@ -83,6 +83,10 @@ export class VersionsEditView extends SceneObjectBase imp
return this._start;
}
+ public get continueToken(): string {
+ return this._continueToken;
+ }
+
public getUrlKey(): string {
return 'versions';
}
@@ -202,7 +206,11 @@ function VersionsEditorSettingsListView({ model }: SceneComponentProps version.checked).length === 2;
const showButtons = model.versions.length > 1;
const hasMore = model.versions.length >= model.limit;
- const isLastPage = model.versions.find((rev) => rev.version === 1);
+ // older versions may have been cleaned up in the db, so also check if the last page is less than the limit, if so, we are at the end
+ let isLastPage = model.versions.find((rev) => rev.version === 1) || model.versions.length % model.limit !== 0;
+ if (config.featureToggles.kubernetesClientDashboardsFolders) {
+ isLastPage = isLastPage || model.continueToken === '';
+ }
const viewModeCompare = (
<>
diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.test.tsx
index 07fdec6b6a1..b3c7a22a257 100644
--- a/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.test.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-externally/ShareExternally.test.tsx
@@ -2,7 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { render } from 'test/test-utils';
import { getDefaultTimeRange, LoadingState } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import {
diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.test.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.test.tsx
index 5e7e71ee6b3..35eaec800d1 100644
--- a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.test.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.test.tsx
@@ -1,7 +1,7 @@
import { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, UrlSyncContextProvider } from '@grafana/scenes';
diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx
index cded7ef3abe..d149d075e02 100644
--- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { advanceTo, clear } from 'jest-date-mock';
import { dateTime } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx
index ac5daf5eac4..d2cbfaa14f7 100644
--- a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx
+++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts
index 33321ca235b..c9b7e2b4a4e 100644
--- a/public/app/features/dashboard-scene/utils/utils.ts
+++ b/public/app/features/dashboard-scene/utils/utils.ts
@@ -446,17 +446,9 @@ export function useDashboard(scene: SceneObject): DashboardScene {
return getDashboardSceneFor(scene);
}
-export function useDashboardState(
- scene: SceneObject
-): DashboardSceneState & { isEditing: boolean; showHiddenElements: boolean } {
+export function useDashboardState(scene: SceneObject): DashboardSceneState {
const dashboard = useDashboard(scene);
- const state = dashboard.useState();
-
- return {
- ...state,
- isEditing: !!state.isEditing,
- showHiddenElements: !!(state.isEditing && state.showHiddenElements),
- };
+ return dashboard.useState();
}
export function useIsConditionallyHidden(scene: RowItem | ResponsiveGridItem): boolean {
diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx
index 021060699b4..8da1d2bb549 100644
--- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx
+++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx
@@ -1,4 +1,4 @@
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { ContextSrv, setContextSrv } from '../../../../core/services/context_srv';
import { PanelModel } from '../../state/PanelModel';
diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
index 16f8d84f237..f311d32d3f1 100644
--- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
@@ -2,6 +2,7 @@ import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
+import { config } from '@grafana/runtime';
import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv';
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
@@ -56,8 +57,7 @@ describe('VersionSettings', () => {
});
test('renders a header and a loading indicator followed by results in a table', async () => {
- // @ts-ignore
- historySrv.getHistoryList.mockResolvedValue(versions);
+ historySrv.getHistoryList = jest.fn().mockResolvedValue(versions);
setup();
expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument();
@@ -75,8 +75,7 @@ describe('VersionSettings', () => {
});
test('does not render buttons if versions === 1', async () => {
- // @ts-ignore
- historySrv.getHistoryList.mockResolvedValue({
+ historySrv.getHistoryList = jest.fn().mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, 1),
});
@@ -93,8 +92,7 @@ describe('VersionSettings', () => {
});
test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => {
- // @ts-ignore
- historySrv.getHistoryList.mockResolvedValue({
+ historySrv.getHistoryList = jest.fn().mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 5),
});
@@ -111,8 +109,7 @@ describe('VersionSettings', () => {
});
test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => {
- // @ts-ignore
- historySrv.getHistoryList.mockResolvedValue({
+ historySrv.getHistoryList = jest.fn().mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT),
});
@@ -134,8 +131,8 @@ describe('VersionSettings', () => {
});
test('clicking show more appends results to the table', async () => {
- historySrv.getHistoryList
- // @ts-ignore
+ historySrv.getHistoryList = jest
+ .fn()
.mockImplementationOnce(() =>
Promise.resolve({
continueToken: versions.continueToken,
@@ -170,14 +167,47 @@ describe('VersionSettings', () => {
});
});
+ test('does not show more button when receiving partial page without version 1', async () => {
+ // Mock a partial page response (less than VERSIONS_FETCH_LIMIT)
+ historySrv.getHistoryList = jest.fn().mockResolvedValueOnce({
+ continueToken: '',
+ versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 5),
+ });
+
+ setup();
+
+ await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
+
+ // Verify that show more button is not present since we got a partial page
+ expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
+ // Verify that compare button is still present
+ expect(screen.getByRole('button', { name: /compare versions/i })).toBeInTheDocument();
+ });
+
+ test('does not show more button when kubernetesClientDashboardsFolders is enabled and continueToken is empty', async () => {
+ config.featureToggles.kubernetesClientDashboardsFolders = true;
+ historySrv.getHistoryList = jest.fn().mockResolvedValueOnce({
+ continueToken: '',
+ versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 1),
+ });
+
+ setup();
+
+ await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
+
+ expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /compare versions/i })).toBeInTheDocument();
+
+ config.featureToggles.kubernetesClientDashboardsFolders = false;
+ });
+
test('selecting two versions and clicking compare button should render compare view', async () => {
- // @ts-ignore
- historySrv.getHistoryList.mockResolvedValue({
+ historySrv.getHistoryList = jest.fn().mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT),
});
- historySrv.getDashboardVersion
- // @ts-ignore
+ historySrv.getDashboardVersion = jest
+ .fn()
.mockImplementationOnce(() => Promise.resolve(diffs.lhs))
.mockImplementationOnce(() => Promise.resolve(diffs.rhs));
diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
index ac4a4e89de6..31ee8c9ba2e 100644
--- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
@@ -125,7 +125,14 @@ export class VersionsSettings extends PureComponent {
}));
isLastPage() {
- return this.state.versions.find((rev) => rev.version === 1);
+ if (config.featureToggles.kubernetesClientDashboardsFolders) {
+ return (
+ this.state.versions.find((rev) => rev.version === 1) ||
+ this.state.versions.length % this.limit !== 0 ||
+ this.continueToken === ''
+ );
+ }
+ return this.state.versions.find((rev) => rev.version === 1) || this.state.versions.length % this.limit !== 0;
}
onCheck = (ev: React.FormEvent, versionId: number) => {
diff --git a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx
index 8fdc82a5dd8..2eb805aa87e 100644
--- a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx
+++ b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { PanelModel } from '../../state/PanelModel';
diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.test.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.test.tsx
index 6387aac97a8..91351c87a85 100644
--- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.test.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.test.tsx
@@ -13,7 +13,7 @@ import {
TimeRange,
toDataFrame,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { getAllOptionEditors, getAllStandardFieldConfigs } from 'app/core/components/OptionsUI/registry';
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx
index 07936f463b8..7e0edec648b 100644
--- a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx
@@ -1,8 +1,7 @@
import { css, cx } from '@emotion/css';
import { Component } from 'react';
-import { renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafana/data';
-import { GrafanaTheme2 } from '@grafana/data/';
+import { GrafanaTheme2, renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, getTemplateSrv } from '@grafana/runtime';
import { Tooltip, PopoverContent, Icon, Themeable2, withTheme2, useStyles2 } from '@grafana/ui';
diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx
index b62d0493c2e..4b003d91c84 100644
--- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx
@@ -7,13 +7,9 @@ import {
PanelPlugin,
StandardEditorContext,
VariableSuggestionsScope,
-} from '@grafana/data';
-import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
-import {
- NestedValueAccess,
PanelOptionsEditorBuilder,
- isNestedPanelOptions,
-} from '@grafana/data/src/utils/OptionsUIBuilders';
+} from '@grafana/data';
+import { NestedValueAccess, isNestedPanelOptions, PanelOptionsSupplier } from '@grafana/data/internal';
import { VizPanel } from '@grafana/scenes';
import { Input } from '@grafana/ui';
import { LibraryVizPanelInfo } from 'app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo';
diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
index b661ef017b8..ab46604bb92 100644
--- a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
+++ b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
@@ -1,5 +1,5 @@
import { PanelPlugin } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { LibraryElementDTOMeta } from '@grafana/schema';
import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { panelModelAndPluginReady, removePanel } from 'app/features/panel/state/reducers';
diff --git a/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx b/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx
index 4bf24218d60..e413a750181 100644
--- a/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx
+++ b/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx
@@ -1,6 +1,6 @@
import { css, cx } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { useStyles2 } from '@grafana/ui';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx
index b83161aa968..5cf08218411 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { useForm } from 'react-hook-form';
-import { GrafanaTheme2, TimeRange } from '@grafana/data/src';
+import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import {
Button,
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx
index eb9a3d8a34f..5156356f470 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx
@@ -1,6 +1,6 @@
import { UseFormRegister } from 'react-hook-form';
-import { TimeRange } from '@grafana/data/src';
+import { TimeRange } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { FieldSet, Label, Switch, TimeRangeInput, Stack, VerticalGroup } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx
index 574e289609a..d1913aa3b9f 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { UseFormRegister } from 'react-hook-form';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { Checkbox, FieldSet, HorizontalGroup, LinkButton, useStyles2, VerticalGroup } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx
index 3850c5e8b17..1df2bbeac48 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import cx from 'classnames';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { config } from '@grafana/runtime';
import { Alert, useStyles2 } from '@grafana/ui';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx
index 12d213f8501..9a0357f0626 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
-import { BootData, DataQuery } from '@grafana/data/src';
+import { BootData, DataQuery } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { reportInteraction, setEchoSrv } from '@grafana/runtime';
import { Panel } from '@grafana/schema';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx
index 8cbfdd8bd57..329e0d5ef08 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { Spinner, useStyles2 } from '@grafana/ui';
import { useGetPublicDashboardQuery } from 'app/features/dashboard/api/publicDashboardApi';
import { publicDashboardPersisted } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx
index 7a14d0a4e45..6ca108fc542 100644
--- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx
+++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx
@@ -1,5 +1,4 @@
-import { TypedVariableModel } from '@grafana/data';
-import { DataSourceRef, DataQuery } from '@grafana/data/src/types/query';
+import { DataSourceRef, DataQuery, TypedVariableModel } from '@grafana/data';
import { DataSourceWithBackend } from '@grafana/runtime';
import { updateConfig } from 'app/core/config';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx
index 1fa176900eb..65a7c39b335 100644
--- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx
+++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx
@@ -1,6 +1,6 @@
import { useEffectOnce } from 'react-use';
-import { sanitizeUrl } from '@grafana/data/src/text/sanitize';
+import { sanitizeUrl } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { DashboardLink } from '@grafana/schema';
diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx
index f36232b7b2b..7beb284f28b 100644
--- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx
+++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx
@@ -3,7 +3,7 @@ import { forwardRef } from 'react';
import { useAsync } from 'react-use';
import { GrafanaTheme2, ScopedVars } from '@grafana/data';
-import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
+import { sanitize, sanitizeUrl } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardLink } from '@grafana/schema';
import { Dropdown, Icon, LinkButton, Button, Menu, ScrollContainer, useStyles2 } from '@grafana/ui';
diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts
index d2b6715e4a6..b184b5d0922 100644
--- a/public/app/features/dashboard/state/DashboardMigrator.test.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts
@@ -1,7 +1,7 @@
import { each, map } from 'lodash';
import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { FieldConfigSource } from '@grafana/schema';
import { config } from 'app/core/config';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts
index e1f6b0a85fa..b595fa3a685 100644
--- a/public/app/features/dashboard/state/DashboardMigrator.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.ts
@@ -27,8 +27,7 @@ import {
ValueMapping,
VariableHide,
} from '@grafana/data';
-import { labelsToFieldsTransformer } from '@grafana/data/src/transformations/transformers/labelsToFields';
-import { mergeTransformer } from '@grafana/data/src/transformations/transformers/merge';
+import { labelsToFieldsTransformer, mergeTransformer } from '@grafana/data/internal';
import { getDataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { DataTransformerConfig } from '@grafana/schema';
import { AxisPlacement, GraphFieldConfig } from '@grafana/ui';
diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts
index 3f52eda79af..bd78d083a74 100644
--- a/public/app/features/dashboard/state/DashboardModel.test.ts
+++ b/public/app/features/dashboard/state/DashboardModel.test.ts
@@ -47,6 +47,20 @@ describe('DashboardModel', () => {
it('should have default properties', () => {
expect(model.panels.length).toBe(0);
});
+
+ it('should have uid if specified', () => {
+ const model = createDashboardModelFixture({ uid: '123' });
+ expect(model.uid).toBe('123');
+ });
+ it('should have null uid if not specified in spec', () => {
+ const model = createDashboardModelFixture();
+ expect(model.uid).toBe(null);
+ });
+
+ it('should have uid if specified in meta', () => {
+ const model = createDashboardModelFixture({}, { uid: '123' });
+ expect(model.uid).toBe('123');
+ });
});
describe('when storing original dashboard data', () => {
diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts
index c54e1743f96..58063976f83 100644
--- a/public/app/features/dashboard/state/DashboardModel.ts
+++ b/public/app/features/dashboard/state/DashboardModel.ts
@@ -140,7 +140,7 @@ export class DashboardModel implements TimeModel {
this.events = new EventBusSrv();
this.id = data.id || null;
// UID is not there for newly created dashboards
- this.uid = data.uid || null;
+ this.uid = data.uid || meta?.uid || null;
this.revision = data.revision ?? undefined;
this.title = data.title ?? 'No Title';
this.description = data.description;
diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts
index a8a7aaae420..092ec0e0947 100644
--- a/public/app/features/dashboard/state/PanelModel.test.ts
+++ b/public/app/features/dashboard/state/PanelModel.test.ts
@@ -11,8 +11,7 @@ import {
PanelMigrationHandler,
PanelTypeChangedHandler,
} from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
-import { mockStandardFieldConfigOptions } from '@grafana/data/test/helpers/fieldConfig';
+import { getPanelPlugin, mockStandardFieldConfigOptions } from '@grafana/data/test';
import { setTemplateSrv } from '@grafana/runtime';
import { queryBuilder } from 'app/features/variables/shared/testing/builders';
diff --git a/public/app/features/dashboard/utils/panel.test.ts b/public/app/features/dashboard/utils/panel.test.ts
index a739dcedab4..0a444a7726a 100644
--- a/public/app/features/dashboard/utils/panel.test.ts
+++ b/public/app/features/dashboard/utils/panel.test.ts
@@ -2,7 +2,7 @@ import { advanceTo, clear } from 'jest-date-mock';
import { ComponentClass } from 'react';
import { dateTime, DateTime, PanelProps, TimeRange } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel';
import { PanelModel } from '../state/PanelModel';
diff --git a/public/app/features/dashboard/utils/timeRange.ts b/public/app/features/dashboard/utils/timeRange.ts
index 79cf1f10244..da144cf427c 100644
--- a/public/app/features/dashboard/utils/timeRange.ts
+++ b/public/app/features/dashboard/utils/timeRange.ts
@@ -1,5 +1,4 @@
-import { DateTime, TimeRange } from '@grafana/data';
-import { dateMath, dateTime, isDateTime } from '@grafana/data/src';
+import { dateMath, dateTime, isDateTime, DateTime, TimeRange } from '@grafana/data';
import { TimeModel } from 'app/features/dashboard/state/TimeModel';
export const getTimeRange = (
diff --git a/public/app/features/datasources/components/CloudInfoBox.tsx b/public/app/features/datasources/components/CloudInfoBox.tsx
index c34c44ce810..17e6b6d26b8 100644
--- a/public/app/features/datasources/components/CloudInfoBox.tsx
+++ b/public/app/features/datasources/components/CloudInfoBox.tsx
@@ -1,5 +1,5 @@
import { DataSourceSettings } from '@grafana/data';
-import { GrafanaEdition } from '@grafana/data/src/types/config';
+import { GrafanaEdition } from '@grafana/data/internal';
import { Alert } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import { config } from 'app/core/config';
diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts
index 5370c5a5789..297c413073d 100644
--- a/public/app/features/datasources/state/buildCategories.test.ts
+++ b/public/app/features/datasources/state/buildCategories.test.ts
@@ -1,5 +1,5 @@
import { DataSourcePluginMeta } from '@grafana/data';
-import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getMockPlugin } from '@grafana/data/test';
import { buildCategories } from './buildCategories';
diff --git a/public/app/features/dimensions/context.ts b/public/app/features/dimensions/context.ts
index a4693ada2a9..bd0755de986 100644
--- a/public/app/features/dimensions/context.ts
+++ b/public/app/features/dimensions/context.ts
@@ -1,4 +1,4 @@
-import { PanelData } from '@grafana/data/src';
+import { PanelData } from '@grafana/data';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
diff --git a/public/app/features/dimensions/scale.ts b/public/app/features/dimensions/scale.ts
index 06f3be04dee..7c82df3c29d 100644
--- a/public/app/features/dimensions/scale.ts
+++ b/public/app/features/dimensions/scale.ts
@@ -1,5 +1,4 @@
-import { DataFrame, Field } from '@grafana/data';
-import { getMinMaxAndDelta } from '@grafana/data/src/field/scale';
+import { getMinMaxAndDelta, DataFrame, Field } from '@grafana/data';
import { ScaleDimensionConfig, ScaleDimensionMode } from '@grafana/schema';
import { DimensionSupplier, ScaleDimensionOptions } from './types';
diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx
index 1610ff0152d..ad39f27c077 100644
--- a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx
+++ b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx
@@ -5,6 +5,7 @@ import * as React from 'react';
import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { TooltipPlacement } from '@grafana/ui/internal';
+import { t } from 'app/core/internationalization';
type CommonProps = {
contentOutlineExpanded?: boolean;
@@ -64,7 +65,10 @@ export function ContentOutlineItemButton({
diff --git a/public/app/features/explore/CorrelationEditorModeBar.tsx b/public/app/features/explore/CorrelationEditorModeBar.tsx
index 01edb0f5b0b..26aaba11a1c 100644
--- a/public/app/features/explore/CorrelationEditorModeBar.tsx
+++ b/public/app/features/explore/CorrelationEditorModeBar.tsx
@@ -6,6 +6,7 @@ import { GrafanaTheme2, colorManipulator } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { Prompt } from 'app/core/components/FormPrompt/Prompt';
+import { Trans } from 'app/core/internationalization';
import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState, useDispatch, useSelector } from 'app/types';
import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal';
@@ -242,7 +243,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl
saveCorrelationPostAction(true);
}}
>
- Save
+ Save
- Exit correlation editor
+ Exit correlation editor
diff --git a/public/app/features/explore/CorrelationHelper.tsx b/public/app/features/explore/CorrelationHelper.tsx
index 14bcd722857..b71aa6b3e18 100644
--- a/public/app/features/explore/CorrelationHelper.tsx
+++ b/public/app/features/explore/CorrelationHelper.tsx
@@ -18,6 +18,7 @@ import {
Icon,
Stack,
} from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { useDispatch, useSelector } from 'app/types';
import { getTransformationVars } from '../correlations/transformations';
@@ -156,7 +157,7 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
}
/>
)}
-
+
The correlation link will appear by the {correlations.resultField} field. You can use the following
variables to set up your correlations:
@@ -179,7 +180,7 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
}
>
-
+
{
}}
/>
-
+
@@ -217,7 +218,7 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
}}
className={styles.transformationAction}
>
- Add transformation
+ Add transformation
{transformations.map((transformation, i) => {
const { type, field, expression, mapValue } = transformation;
@@ -241,14 +242,17 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
{
setTransformationIdxToEdit(i);
setShowTransformationAddModal(true);
}}
/>
setTransformations(transformations.filter((_, idx) => i !== idx))}
closeOnConfirm
/>
diff --git a/public/app/features/explore/CorrelationTransformationAddModal.tsx b/public/app/features/explore/CorrelationTransformationAddModal.tsx
index fe555e26274..ab70ddb9743 100644
--- a/public/app/features/explore/CorrelationTransformationAddModal.tsx
+++ b/public/app/features/explore/CorrelationTransformationAddModal.tsx
@@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form';
import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data';
import { Button, Field, Icon, Input, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import {
getSupportedTransTypeDetails,
@@ -137,7 +138,7 @@ export const CorrelationTransformationAddModal = ({
A transformation extracts variables out of a single field. These variables will be available along with your
field variables.
-
+
(
@@ -152,7 +153,7 @@ export const CorrelationTransformationAddModal = ({
options={Object.entries(fieldList).map((entry) => {
return { label: entry[0], value: entry[0] };
})}
- aria-label="field"
+ aria-label={t('explore.correlation-transformation-add-modal.aria-label-field', 'Field')}
/>
)}
name={`field` as const}
@@ -168,7 +169,7 @@ export const CorrelationTransformationAddModal = ({
autoEscape={false}
/>
-
+
(
@@ -183,7 +184,7 @@ export const CorrelationTransformationAddModal = ({
});
}}
options={getTransformOptions()}
- aria-label="type"
+ aria-label={t('explore.correlation-transformation-add-modal.aria-label-type', 'Type')}
/>
)}
name={`type` as const}
@@ -193,7 +194,10 @@ export const CorrelationTransformationAddModal = ({
+
) : (
'Expression'
)
@@ -208,9 +212,12 @@ export const CorrelationTransformationAddModal = ({
+
) : (
- 'Variable Name'
+ 'Variable name'
)
}
htmlFor={`${id}-mapValue`}
@@ -232,7 +239,7 @@ export const CorrelationTransformationAddModal = ({
)}
- Cancel
+ Cancel
onSave(getValues())} disabled={!validToSave}>
{transformationToEdit ? 'Edit transformation' : 'Add transformation to correlation'}
diff --git a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx
index 5aaf6f2fc9e..ca55adea465 100644
--- a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx
+++ b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx
@@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import { Button, Modal } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
interface UnsavedChangesModalProps {
message: string;
@@ -21,13 +22,15 @@ export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel, me
{message}
- Cancel
+ Cancel
- Continue without saving
+
+ Continue without saving
+
- Save correlation
+ Save correlation
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index 206b006c66b..f1148f3c735 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -31,6 +31,7 @@ import {
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
+import { t } from 'app/core/internationalization';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { StoreState } from 'app/types';
@@ -371,7 +372,7 @@ export class Explore extends PureComponent {
const { graphResult, timeZone, queryResponse, showFlameGraph } = this.props;
return (
-
+
{
renderTablePanel(width: number) {
const { exploreId, timeZone } = this.props;
return (
-
+
{
renderRawPrometheus(width: number) {
const { exploreId, datasourceInstance, timeZone } = this.props;
return (
-
+
{
gap: theme.spacing(1),
});
return (
-
+
{
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props;
return (
-
+
{
const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown';
return (
-
+
{
renderFlameGraphPanel() {
const { queryResponse } = this.props;
return (
-
+
);
@@ -508,7 +530,7 @@ export class Explore extends PureComponent {
return (
// If there is no data (like 404) we show a separate error so no need to show anything here
dataFrames.length && (
-
+
{
{datasourceInstance ? (
<>
-
+
{correlationsBox}
diff --git a/public/app/features/explore/ExploreRunQueryButton.tsx b/public/app/features/explore/ExploreRunQueryButton.tsx
index 48c7831c9d2..20f6fa3272b 100644
--- a/public/app/features/explore/ExploreRunQueryButton.tsx
+++ b/public/app/features/explore/ExploreRunQueryButton.tsx
@@ -118,7 +118,14 @@ export function ExploreRunQueryButton({
return (
setOpenRunQueryButton(state)} placement="bottom-start" overlay={menu}>
-
+
{t('explore.run-query.run-query-button', 'Run query')}
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index 85166280a89..afc59393b6c 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -227,7 +227,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
- Outline
+ Outline
,
Pause the live stream> : <>Start live stream your logs>}
+ content={
+ isLive && !isPaused ? (
+ <>
+ Pause the live stream
+ >
+ ) : (
+ <>
+ Start live stream your logs
+ >
+ )
+ }
placement="bottom"
>
- Stop and exit the live stream>} placement="bottom">
+
+
+ Stop and exit the live stream
+
+ >
+ }
+ placement="bottom"
+ >
diff --git a/public/app/features/explore/Logs/LiveLogs.tsx b/public/app/features/explore/Logs/LiveLogs.tsx
index 07c33ce6a0c..596d956887a 100644
--- a/public/app/features/explore/Logs/LiveLogs.tsx
+++ b/public/app/features/explore/Logs/LiveLogs.tsx
@@ -6,6 +6,7 @@ import tinycolor from 'tinycolor2';
import { LogRowModel, dateTimeFormat, GrafanaTheme2, LogsSortOrder } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { Button, Themeable2, withTheme2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { LogMessageAnsi } from '../../logs/components/LogMessageAnsi';
import { getLogRowStyles } from '../../logs/components/getLogRowStyles';
@@ -172,10 +173,10 @@ class LiveLogs extends PureComponent {
{isPaused ? 'Resume' : 'Pause'}
- Clear logs
+ Clear logs
- Exit live mode
+ Exit live mode
{isPaused ||
(this.rowsToRender().length > 0 && (
diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx
index 958bad54ad4..85b5906eef6 100644
--- a/public/app/features/explore/Logs/Logs.test.tsx
+++ b/public/app/features/explore/Logs/Logs.test.tsx
@@ -16,7 +16,7 @@ import {
ExploreLogsPanelState,
DataQuery,
} from '@grafana/data';
-import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
+import { organizeFieldsTransformer } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx
index 5bf10d2dac2..1934a768a6e 100644
--- a/public/app/features/explore/Logs/Logs.tsx
+++ b/public/app/features/explore/Logs/Logs.tsx
@@ -49,7 +49,7 @@ import {
withTheme2,
} from '@grafana/ui';
import { mapMouseEventToMode } from '@grafana/ui/internal';
-import { Trans } from 'app/core/internationalization';
+import { Trans, t } from 'app/core/internationalization';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
@@ -804,7 +804,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => {
/>
)}
= (props: Props) => {
titleItems={[
config.featureToggles.logsExploreTableVisualisation ? (
visualisationType === 'logs' ? null : (
-
+
)
@@ -868,7 +868,11 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => {
{visualisationType !== 'table' && (
-
+
= (props: Props) => {
id={`show-time_${exploreId}`}
/>
-
+
= (props: Props) => {
id={`unique-labels_${exploreId}`}
/>
-
+
= (props: Props) => {
id={`wrap-lines_${exploreId}`}
/>
-
+
= (props: Props) => {
id={`prettify_${exploreId}`}
/>
-
+
({
label: capitalize(dedupType),
@@ -920,7 +940,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => {
-
+
{(controls) => (
- Show original line
+ Show original line
),
}
@@ -165,8 +166,11 @@ export const LogsMetaRow = memo(
}
const downloadMenu = (
+ {/* eslint-disable-next-line @grafana/no-untranslated-strings */}
downloadLogs(DownloadFormat.Text)} />
+ {/* eslint-disable-next-line @grafana/no-untranslated-strings */}
downloadLogs(DownloadFormat.Json)} />
+ {/* eslint-disable-next-line @grafana/no-untranslated-strings */}
downloadLogs(DownloadFormat.CSV)} />
);
@@ -185,7 +189,7 @@ export const LogsMetaRow = memo(
{!config.exploreHideLogsDownload && (
- Download
+ Download
)}
diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx
index 63ec5219b06..4af65d19ef8 100644
--- a/public/app/features/explore/Logs/LogsSamplePanel.tsx
+++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx
@@ -13,6 +13,7 @@ import {
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Button, Collapse, Icon, Tooltip, useStyles2 } from '@grafana/ui';
+import { Trans, t } from 'app/core/internationalization';
import store from 'app/core/store';
import { LogRows } from '../../logs/components/LogRows';
@@ -69,7 +70,9 @@ export function LogsSamplePanel(props: Props) {
return (
- Open logs in split view
+
+ Open logs in split view
+
);
};
@@ -80,12 +83,23 @@ export function LogsSamplePanel(props: Props) {
LogsSamplePanelContent = null;
} else if (queryResponse.error !== undefined) {
LogsSamplePanelContent = (
-
+
);
} else if (queryResponse.state === LoadingState.Loading) {
- LogsSamplePanelContent = Logs sample is loading... ;
+ LogsSamplePanelContent = (
+
+ Logs sample is loading...
+
+ );
} else if (queryResponse.data.length === 0 || queryResponse.data.every((frame) => frame.length === 0)) {
- LogsSamplePanelContent = No logs sample data. ;
+ LogsSamplePanelContent = (
+
+ No logs sample data.
+
+ );
} else {
const logs = dataFrameToLogsModel(queryResponse.data);
LogsSamplePanelContent = (
diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx
index 6dff92ec2c5..a8fa211431e 100644
--- a/public/app/features/explore/Logs/LogsTable.test.tsx
+++ b/public/app/features/explore/Logs/LogsTable.test.tsx
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { ComponentProps } from 'react';
import { DataFrame, FieldType, LogsSortOrder, standardTransformersRegistry, toUtc } from '@grafana/data';
-import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
+import { organizeFieldsTransformer } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
diff --git a/public/app/features/explore/Logs/LogsTableActiveFields.tsx b/public/app/features/explore/Logs/LogsTableActiveFields.tsx
index eb833030106..c228c7aaf7b 100644
--- a/public/app/features/explore/Logs/LogsTableActiveFields.tsx
+++ b/public/app/features/explore/Logs/LogsTableActiveFields.tsx
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from '@hello-pangea/dnd';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { LogsTableEmptyFields } from './LogsTableEmptyFields';
diff --git a/public/app/features/explore/Logs/LogsTableEmptyFields.tsx b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx
index 1704050a461..0b68ae8fef7 100644
--- a/public/app/features/explore/Logs/LogsTableEmptyFields.tsx
+++ b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
function getStyles(theme: GrafanaTheme2) {
return {
@@ -16,5 +17,9 @@ function getStyles(theme: GrafanaTheme2) {
export function LogsTableEmptyFields() {
const theme = useTheme2();
const styles = getStyles(theme);
- return No fields
;
+ return (
+
+ No fields
+
+ );
}
diff --git a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx
index 10308e761a7..d50057aeaa7 100644
--- a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx
+++ b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx
@@ -1,7 +1,8 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { LogsTableActiveFields } from './LogsTableActiveFields';
import { LogsTableAvailableFields } from './LogsTableAvailableFields';
@@ -71,7 +72,9 @@ export const LogsTableMultiSelect = (props: {
id={'selected-fields'}
/>
- Fields
+
+ Fields
+
{props.draggable && (
1 && (
Loading...;
+ return (
+
+ Loading...
+
+ );
} else if (timeoutError && !canShowPartialData) {
return (
@@ -131,13 +135,24 @@ export const LogsVolumePanelList = ({
/>
);
} else if (logsVolumeData?.error !== undefined && !canShowPartialData) {
- return ;
+ return (
+
+ );
}
if (numberOfLogVolumes === 0 && logsVolumeData?.state !== LoadingState.Streaming) {
return (
-
+
No volume information available for the current queries and time range.
@@ -148,7 +163,7 @@ export const LogsVolumePanelList = ({
{timeoutError && canShowPartialData && (
-
+
diff --git a/public/app/features/explore/Logs/utils/testMocks.test.ts b/public/app/features/explore/Logs/utils/testMocks.test.ts
index 55a19433b20..93ddc7b2eeb 100644
--- a/public/app/features/explore/Logs/utils/testMocks.test.ts
+++ b/public/app/features/explore/Logs/utils/testMocks.test.ts
@@ -1,6 +1,4 @@
-import { DataFrame, Field, FieldType } from '@grafana/data/src';
-
-import { DataFrameType } from '../../../../../../packages/grafana-data';
+import { DataFrame, DataFrameType, Field, FieldType } from '@grafana/data';
export const getMockLokiFrame = (override?: Partial) => {
const testDataFrame: DataFrame = {
diff --git a/public/app/features/explore/NoData.tsx b/public/app/features/explore/NoData.tsx
index 130f38750ef..38384793050 100644
--- a/public/app/features/explore/NoData.tsx
+++ b/public/app/features/explore/NoData.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, PanelContainer } from '@grafana/ui';
export const NoData = () => {
diff --git a/public/app/features/explore/NoDataSourceCallToAction.tsx b/public/app/features/explore/NoDataSourceCallToAction.tsx
index 7fe19a35a09..42c2fe15e78 100644
--- a/public/app/features/explore/NoDataSourceCallToAction.tsx
+++ b/public/app/features/explore/NoDataSourceCallToAction.tsx
@@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, CallToActionCard, Icon, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
+import { Trans } from 'app/core/internationalization';
import { AccessControlAction } from 'app/types';
function getCardStyles(theme: GrafanaTheme2) {
@@ -25,21 +26,26 @@ export const NoDataSourceCallToAction = () => {
const footer = (
<>
- <> ProTip: You can also define data sources through configuration files. >
+ <>
+
+ {' '}
+ ProTip: You can also define data sources through configuration files.{' '}
+
+ >
- Learn more
+ Learn more
>
);
const ctaElement = (
- Add data source
+ Add data source
);
diff --git a/public/app/features/explore/PrometheusListView/ItemLabels.tsx b/public/app/features/explore/PrometheusListView/ItemLabels.tsx
index 4f0884ab71b..4fb465818cc 100644
--- a/public/app/features/explore/PrometheusListView/ItemLabels.tsx
+++ b/public/app/features/explore/PrometheusListView/ItemLabels.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { Field, GrafanaTheme2 } from '@grafana/data/';
+import { Field, GrafanaTheme2 } from '@grafana/data';
import { InstantQueryRefIdIndex } from '@grafana/prometheus';
import { useStyles2 } from '@grafana/ui';
diff --git a/public/app/features/explore/PrometheusListView/ItemValues.tsx b/public/app/features/explore/PrometheusListView/ItemValues.tsx
index 2cb61be35c0..ab46049a540 100644
--- a/public/app/features/explore/PrometheusListView/ItemValues.tsx
+++ b/public/app/features/explore/PrometheusListView/ItemValues.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/';
+import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { rawListItemColumnWidth, rawListPaddingToHoldSpaceForCopyIcon, RawListValue } from './RawListItem';
diff --git a/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx b/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx
index 8f3d27dbac8..896b5dd7ba8 100644
--- a/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx
+++ b/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, within } from '@testing-library/react';
-import { FieldType, FormattedValue, toDataFrame } from '@grafana/data/src';
+import { FieldType, FormattedValue, toDataFrame } from '@grafana/data';
import RawListContainer, { RawListContainerProps } from './RawListContainer';
diff --git a/public/app/features/explore/PrometheusListView/RawListContainer.tsx b/public/app/features/explore/PrometheusListView/RawListContainer.tsx
index ee8dedfd5ab..eb0717b6c43 100644
--- a/public/app/features/explore/PrometheusListView/RawListContainer.tsx
+++ b/public/app/features/explore/PrometheusListView/RawListContainer.tsx
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { VariableSizeList as List } from 'react-window';
-import { DataFrame, Field as DataFrameField } from '@grafana/data/';
+import { DataFrame, Field as DataFrameField } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src';
import { Field, Switch } from '@grafana/ui';
diff --git a/public/app/features/explore/PrometheusListView/RawListItem.tsx b/public/app/features/explore/PrometheusListView/RawListItem.tsx
index b5a4e5b9433..9a5e84e318c 100644
--- a/public/app/features/explore/PrometheusListView/RawListItem.tsx
+++ b/public/app/features/explore/PrometheusListView/RawListItem.tsx
@@ -1,10 +1,11 @@
import { css } from '@emotion/css';
import { useCopyToClipboard } from 'react-use';
-import { Field, GrafanaTheme2 } from '@grafana/data/';
+import { Field, GrafanaTheme2 } from '@grafana/data';
import { isValidLegacyName, utf8Support } from '@grafana/prometheus/src/utf8_support';
import { reportInteraction } from '@grafana/runtime/src';
import { IconButton, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { ItemLabels } from './ItemLabels';
import { ItemValues } from './ItemValues';
@@ -130,7 +131,7 @@ const RawListItem = ({ listItemData, listKey, totalNumberOfValues, valueLabels,
{
reportInteraction('grafana_explore_prometheus_instant_query_ui_raw_toggle_expand');
copyToClipboard(stringRep);
diff --git a/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx b/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx
index bbc7962c918..8d9ecb28fe2 100644
--- a/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx
+++ b/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/';
+import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { RawListValue } from './RawListItem';
diff --git a/public/app/features/explore/SupplementaryResultError.tsx b/public/app/features/explore/SupplementaryResultError.tsx
index cd5047c19cc..e4a95590a87 100644
--- a/public/app/features/explore/SupplementaryResultError.tsx
+++ b/public/app/features/explore/SupplementaryResultError.tsx
@@ -3,6 +3,7 @@ import { ReactNode, useCallback, useState } from 'react';
import { DataQueryError, GrafanaTheme2 } from '@grafana/data';
import { Alert, AlertVariant, Button, useTheme2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
type Props = {
error?: DataQueryError;
@@ -50,7 +51,7 @@ export function SupplementaryResultError(props: Props) {
setIsOpen(true);
}}
>
- Show details
+ Show details
) : (
message
diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx
index 3f8d46cc5a5..8b716bef3e1 100644
--- a/public/app/features/explore/TraceView/TraceView.test.tsx
+++ b/public/app/features/explore/TraceView/TraceView.test.tsx
@@ -86,14 +86,14 @@ describe('TraceView', () => {
it('toggles detailState', async () => {
renderTraceViewNew();
- expect(screen.queryByText(/Span Attributes/)).toBeFalsy();
+ expect(screen.queryByText(/Span attributes/)).toBeFalsy();
const spanView = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[0];
await userEvent.click(spanView);
- expect(screen.queryByText(/Span Attributes/)).toBeTruthy();
+ expect(screen.queryByText(/Span attributes/)).toBeTruthy();
await userEvent.click(spanView);
- screen.debug(screen.queryAllByText(/Span Attributes/));
- expect(screen.queryByText(/Span Attributes/)).toBeFalsy();
+ screen.debug(screen.queryAllByText(/Span attributes/));
+ expect(screen.queryByText(/Span attributes/)).toBeFalsy();
});
it('shows timeline ticks', () => {
diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx
index 3bd40c6e45f..a3f25ec5f54 100644
--- a/public/app/features/explore/TraceView/TraceView.tsx
+++ b/public/app/features/explore/TraceView/TraceView.tsx
@@ -20,6 +20,7 @@ import { getTemplateSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { TempoQuery } from '@grafana-plugins/tempo/types';
+import { Trans } from 'app/core/internationalization';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { useDispatch, useSelector } from 'app/types';
@@ -245,7 +246,9 @@ export function TraceView(props: Props) {
/>
>
) : (
- No data
+
+ No data
+
)}
>
);
diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx
index 0d1bf7c5c66..dfc1a9607de 100644
--- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx
+++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx
@@ -62,9 +62,9 @@ describe('TraceViewContainer', () => {
it('toggles collapses and expands all levels', async () => {
renderTraceViewContainer();
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
- await user.click(screen.getByLabelText('Collapse All'));
+ await user.click(screen.getByLabelText('Collapse all'));
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1);
- await user.click(screen.getByLabelText('Expand All'));
+ await user.click(screen.getByLabelText('Expand all'));
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
});
diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx
index 4d0e114c865..9cd834d198c 100644
--- a/public/app/features/explore/TraceView/TraceViewContainer.tsx
+++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { DataFrame, SplitOpen, TimeRange } from '@grafana/data';
import { PanelChrome } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { StoreState, useSelector } from 'app/types';
import { TraceView } from './TraceView';
@@ -29,7 +30,7 @@ export function TraceViewContainer(props: Props) {
}
return (
-
+
- Give feedback
+ Give feedback
)}
diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx
index 4f25b3ad677..a20fa5d2bb6 100644
--- a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx
+++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx
@@ -21,6 +21,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
import { getButtonStyles } from '@grafana/ui/internal';
+import { Trans } from 'app/core/internationalization';
import { Trace } from '../../types';
@@ -146,7 +147,9 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
if (spanFilterMatches.size === 0) {
metadata = (
<>
- 0 matches
+
+ 0 matches
+
{getTooltip(
'There are 0 span matches for the filters selected. Please try removing some of the selected filters.'
)}
@@ -202,7 +205,7 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
role="button"
tabIndex={buttonEnabled ? 0 : -1}
>
- Prev
+ Prev
- Next
+ Next
>
diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx
index b3fb9be4941..3db131bbfb4 100644
--- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx
+++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx
@@ -18,6 +18,7 @@ import React, { useState, useEffect, memo, useCallback } from 'react';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { IntervalInput } from '@grafana/o11y-ds-frontend';
import { Collapse, HorizontalGroup, Icon, InlineField, InlineFieldRow, Select, Tooltip, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { defaultFilters, SearchProps } from '../../../useSearch';
import { getTraceServiceNames, getTraceSpanNames } from '../../../utils/tags';
@@ -139,21 +140,24 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
-
+
setSpanFiltersSearch({ ...search, serviceNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.serviceNameOperator}
/>
setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })}
onOpenMenu={getServiceNames}
options={serviceNames || (search.serviceName ? [search.serviceName].map(toOption) : [])}
- placeholder="All service names"
+ placeholder={t('explore.span-filters.placeholder-all-service-names', 'All service names')}
value={search.serviceName || null}
defaultValue={search.serviceName || null}
/>
@@ -167,21 +171,21 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
/>
-
+
setSpanFiltersSearch({ ...search, spanNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.spanNameOperator}
/>
setSpanFiltersSearch({ ...search, spanName: v?.value || '' })}
onOpenMenu={getSpanNames}
options={spanNames || (search.spanName ? [search.spanName].map(toOption) : [])}
- placeholder="All span names"
+ placeholder={t('explore.span-filters.placeholder-all-span-names', 'All span names')}
value={search.spanName || null}
/>
@@ -189,13 +193,13 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
setSpanFiltersSearch({ ...search, fromOperator: v.value! })}
options={[toOption('>'), toOption('>=')]}
value={search.fromOperator}
@@ -212,7 +216,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
/>
setSpanFiltersSearch({ ...search, toOperator: v.value! })}
options={[toOption('<'), toOption('<=')]}
value={search.toOperator}
@@ -230,7 +234,11 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
-
+
onTagChange(tag, v)}
onOpenMenu={getTagKeys}
options={tagKeys || (tag.key ? [tag.key].map(toOption) : [])}
- placeholder="Select tag"
+ placeholder={t('explore.span-filters-tags.placeholder-select-tag', 'Select tag')}
value={tag.key || null}
/>
{
setSearch({
...search,
@@ -131,7 +132,7 @@ export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys,
{(tag.operator === '=' || tag.operator === '!=') && (
{
@@ -143,13 +144,13 @@ export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys,
});
}}
options={tagValues[tag.id] ? tagValues[tag.id] : tag.value ? [tag.value].map(toOption) : []}
- placeholder="Select value"
+ placeholder={t('explore.span-filters-tags.placeholder-select-value', 'Select value')}
value={tag.value}
/>
)}
{(tag.operator === '=~' || tag.operator === '!~') && (
{
setSearch({
...search,
@@ -158,7 +159,7 @@ export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys,
}),
});
}}
- placeholder="Tag value"
+ placeholder={t('explore.span-filters-tags.placeholder-tag-value', 'Tag value')}
width={18}
value={tag.value || ''}
/>
@@ -166,21 +167,21 @@ export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys,
{(tag.key || tag.value || search.tags.length > 1) && (
removeTag(tag.id)}
- tooltip="Remove tag"
+ tooltip={t('explore.span-filters-tags.tooltip-remove-tag', 'Remove tag')}
/>
)}
{(tag.key || tag.value) && i === search.tags.length - 1 && (
)}
diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx
index e899b3faab9..f3d878d981b 100644
--- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx
+++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx
@@ -18,6 +18,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { withTheme2, stylesFactory, Button } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { autoColor } from '../../Theme';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate, TNil } from '../../index';
@@ -354,7 +355,7 @@ export class UnthemedViewingLayer extends React.PureComponent
- Reset Selection
+ Reset selection
)}
{arrow}
- References
+
+ References
+
{' '}
({data.length})
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx
index ccf70c02b05..5694657462a 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx
@@ -16,6 +16,7 @@ import { FlameGraph } from '@grafana/flamegraph';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { Query } from 'app/plugins/datasource/grafana-pyroscope-datasource/types';
@@ -174,7 +175,9 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) {
return (
-
Flame graph
+
+ Flame graph
+
config.theme2}
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx
index 1ad71c04cbd..b326a7aee79 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx
@@ -216,13 +216,13 @@ describe('', () => {
it('renders the span tags', async () => {
render( );
- await userEvent.click(screen.getByRole('switch', { name: /Span Attributes/ }));
+ await userEvent.click(screen.getByRole('switch', { name: /Span attributes/ }));
expect(props.tagsToggle).toHaveBeenLastCalledWith(span.spanID);
});
it('renders the process tags', async () => {
render( );
- await userEvent.click(screen.getByRole('switch', { name: /Resource Attributes/ }));
+ await userEvent.click(screen.getByRole('switch', { name: /Resource attributes/ }));
expect(props.processToggle).toHaveBeenLastCalledWith(span.spanID);
});
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx
index 7a6ec6dbc17..239fa28b844 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx
@@ -29,6 +29,7 @@ import {
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { TimeZone } from '@grafana/schema';
import { Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import { autoColor } from '../../Theme';
@@ -321,7 +322,7 @@ export default function SpanDetail(props: SpanDetailProps) {
tagsToggle(spanID)}
@@ -330,7 +331,7 @@ export default function SpanDetail(props: SpanDetailProps) {
processToggle(spanID)}
@@ -352,7 +353,11 @@ export default function SpanDetail(props: SpanDetailProps) {
Warnings}
+ label={
+
+ Warnings
+
+ }
data={warnings}
isOpen={isWarningsOpen}
onToggle={() => warningsToggle(spanID)}
@@ -360,7 +365,7 @@ export default function SpanDetail(props: SpanDetailProps) {
)}
{stackTraces?.length ? (
{
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx
index ea0239f8df6..4f2f68f9c5b 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx
@@ -61,8 +61,8 @@ export const SpanLinksMenu = ({ links, datasourceType, color }: SpanLinksProps)
onClick={(e) => {
setIsMenuOpen(true);
setMenuPosition({
- x: e.pageX,
- y: e.pageY,
+ x: e.clientX,
+ y: e.clientY,
});
}}
className={styles.button}
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx
index 19cde0e0e96..251c2923b26 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx
@@ -35,8 +35,8 @@ describe('TimelineCollapser test', () => {
setup();
expect(screen.getByTestId('TimelineCollapser')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Expand All' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Collapse All' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Expand all' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Collapse all' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand +1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse +1' })).toBeInTheDocument();
});
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx
index 457c54f1feb..9811f618b36 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx
@@ -15,6 +15,7 @@
import { css } from '@emotion/css';
import { IconButton, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
const getStyles = () => ({
TimelineCollapser: css({
@@ -38,17 +39,29 @@ export function TimelineCollapser(props: CollapserProps) {
const styles = useStyles2(getStyles);
return (
-
-
+
+
{
it('renders the collapser controls', () => {
setup();
- expect(screen.getByRole('button', { name: 'Expand All' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Collapse All' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Expand all' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Collapse all' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand +1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse +1' })).toBeInTheDocument();
});
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx
index d84d9e984c5..6a8127b8cb1 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx
@@ -23,6 +23,7 @@ import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { PEER_SERVICE } from '../constants/tag-keys';
import { CriticalPathSection, SpanBarOptions, SpanLinkFunc, TNil } from '../types';
@@ -655,7 +656,7 @@ export class UnthemedVirtualizedTraceView extends React.Component
)}
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.test.tsx
index 7ac1261dc05..b95d544dd11 100644
--- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.test.tsx
+++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.test.tsx
@@ -67,8 +67,8 @@ describe('', () => {
const expandOne = screen.getByRole('button', { name: 'Expand +1' });
const collapseOne = screen.getByRole('button', { name: 'Collapse +1' });
- const expandAll = screen.getByRole('button', { name: 'Expand All' });
- const collapseAll = screen.getByRole('button', { name: 'Collapse All' });
+ const expandAll = screen.getByRole('button', { name: 'Expand all' });
+ const collapseAll = screen.getByRole('button', { name: 'Collapse all' });
expect(expandOne).toBeInTheDocument();
expect(collapseOne).toBeInTheDocument();
diff --git a/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx b/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx
index 664e1e6e2b3..3b4c7bae65a 100644
--- a/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx
+++ b/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx
@@ -15,6 +15,7 @@
import * as React from 'react';
import { IconButton, Input } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
type Props = {
value: string | undefined;
@@ -34,13 +35,21 @@ export default class SearchBarInput extends React.PureComponent {
const { value } = this.props;
const suffix = (
- <>{value && value.length && }>
+ <>
+ {value && value.length && (
+
+ )}
+ >
);
return (
this.props.onChange(e.currentTarget.value)}
suffix={suffix}
value={value}
diff --git a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx
index 11710cef528..0a5df0cec61 100644
--- a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx
+++ b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx
@@ -9,6 +9,7 @@ import {
} from '@grafana/data';
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
export interface SpanBarOptions {
type?: string;
@@ -32,7 +33,12 @@ export default function SpanBarSettings({ options, onOptionsChange }: Props) {
return (
-
+
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', {
...options.jsonData.spanBar,
@@ -82,7 +88,7 @@ export const SpanBarSection = ({ options, onOptionsChange }: DataSourcePluginOpt
return (
- DraggableManager demo
+
+ DraggableManager demo
+
- Dragging a Divider
+
+ Dragging a divider
+
Click and drag the gray divider in the colored area, below.
Value: {dividerPosition.toFixed(3)}
@@ -57,8 +63,14 @@ export default class DraggableManagerDemo extends PureComponent<{}, DraggableMan
- Dragging a Sub-Region
- Click and drag horizontally somewhere in the colored area, below.
+
+ Dragging a sub-region
+
+
+
+ Click and drag horizontally somewhere in the colored area, below.
+
+
Value: {regionDragging && regionDragging.map((n) => n.toFixed(3)).join(', ')}
diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx
index 662876c3b21..098e7292bb0 100644
--- a/public/app/features/explore/TraceView/createSpanLink.tsx
+++ b/public/app/features/explore/TraceView/createSpanLink.tsx
@@ -22,6 +22,7 @@ import { PromQuery } from '@grafana/prometheus';
import { getTemplateSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Icon } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { LokiQuery } from '../../../plugins/datasource/loki/types';
@@ -247,7 +248,15 @@ function legacyCreateSpanLinkFactory(
href: link.href,
title: 'Related logs',
onClick: link.onClick,
- content:
,
+ content: (
+
+ ),
field,
type: SpanLinkType.Logs,
});
@@ -308,7 +317,15 @@ function legacyCreateSpanLinkFactory(
title: query?.name,
href: link.href,
onClick: link.onClick,
- content:
,
+ content: (
+
+ ),
field,
type: SpanLinkType.Metrics,
});
@@ -359,7 +376,12 @@ function legacyCreateSpanLinkFactory(
links.push({
title: 'Session for this span',
href: feO11yLink,
- content:
,
+ content: (
+
+ ),
field,
type: SpanLinkType.Session,
});
diff --git a/public/app/features/explore/extensions/ConfirmNavigationModal.tsx b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx
index 1d5c35302ca..8f93a1336fa 100644
--- a/public/app/features/explore/extensions/ConfirmNavigationModal.tsx
+++ b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx
@@ -3,6 +3,7 @@ import { ReactElement } from 'react';
import { locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Button, Modal, Stack } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
type Props = {
onDismiss: () => void;
@@ -25,13 +26,13 @@ export function ConfirmNavigationModal(props: Props): ReactElement {
- Cancel
+ Cancel
- Open in new tab
+ Open in new tab
- Open
+ Open
diff --git a/public/app/features/explore/extensions/toolbar/BasicExtensions.tsx b/public/app/features/explore/extensions/toolbar/BasicExtensions.tsx
index 5332ac6bfd6..9b3fab23ed4 100644
--- a/public/app/features/explore/extensions/toolbar/BasicExtensions.tsx
+++ b/public/app/features/explore/extensions/toolbar/BasicExtensions.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react';
import { Dropdown, ToolbarButton } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
@@ -38,7 +39,12 @@ export function BasicExtensions(props: ExtensionDropdownProps) {
return (
<>
-
+
Add
diff --git a/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
index 7433c74393c..23e566358b7 100644
--- a/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
+++ b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
@@ -1,6 +1,7 @@
import { first } from 'lodash';
import { Dropdown, ToolbarButton } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { Trans } from '../../../../core/internationalization';
import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
@@ -29,7 +30,7 @@ export function QuerylessAppsExtensions(props: ExtensionDropdownProps) {
<>
{
props.onChange({ ...props.query, expr: event.target.value });
diff --git a/public/app/features/explore/state/main.test.ts b/public/app/features/explore/state/main.test.ts
index 28fe5009234..d52a1be7211 100644
--- a/public/app/features/explore/state/main.test.ts
+++ b/public/app/features/explore/state/main.test.ts
@@ -1,7 +1,6 @@
import { thunkTester } from 'test/core/thunk/thunkTester';
-import { dateTime, ExploreUrlState } from '@grafana/data';
-import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
+import { dateTime, ExploreUrlState, serializeStateToUrlParam } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
diff --git a/public/app/features/live/centrifuge/LiveDataStream.ts b/public/app/features/live/centrifuge/LiveDataStream.ts
index a4b6eb80255..6d0787c205f 100644
--- a/public/app/features/live/centrifuge/LiveDataStream.ts
+++ b/public/app/features/live/centrifuge/LiveDataStream.ts
@@ -12,7 +12,7 @@ import {
LoadingState,
StreamingDataFrame,
} from '@grafana/data';
-import { getStreamingFrameOptions } from '@grafana/data/src/dataframe/StreamingDataFrame';
+import { getStreamingFrameOptions } from '@grafana/data/internal';
import { LiveDataStreamOptions, StreamingFrameAction, StreamingFrameOptions } from '@grafana/runtime/src/services/live';
import { toDataQueryError } from '@grafana/runtime/src/utils/toDataQueryError';
diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx
index 9b9792c2849..90b10988e73 100644
--- a/public/app/features/logs/components/InfiniteScroll.test.tsx
+++ b/public/app/features/logs/components/InfiniteScroll.test.tsx
@@ -2,8 +2,7 @@ import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect, useRef, useState } from 'react';
-import { CoreApp, LogRowModel, dateTimeForTimeZone } from '@grafana/data';
-import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
+import { CoreApp, LogRowModel, dateTimeForTimeZone, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { LogsSortOrder } from '@grafana/schema';
@@ -16,7 +15,7 @@ const absoluteRange = {
from: 1702578600000,
to: 1702578900000,
};
-const defaultRange = convertRawToRange({
+const defaultRange = rangeUtil.convertRawToRange({
from: dateTimeForTimeZone(defaultTz, absoluteRange.from),
to: dateTimeForTimeZone(defaultTz, absoluteRange.to),
});
diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx
index 5ebe5516332..d75327e0a2e 100644
--- a/public/app/features/logs/components/InfiniteScroll.tsx
+++ b/public/app/features/logs/components/InfiniteScroll.tsx
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
-import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange } from '@grafana/data';
-import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil';
+import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange, rangeUtil } from '@grafana/data';
+// import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/internal';
import { config, reportInteraction } from '@grafana/runtime';
import { LogsSortOrder, TimeZone } from '@grafana/schema';
import { Button, Icon } from '@grafana/ui';
@@ -141,8 +141,8 @@ export const InfiniteScroll = ({
}, [loadMoreLogs, loading, range, rows, scrollElement, sortOrder, timeZone, topScrollEnabled]);
// We allow "now" to move when using relative time, so we hide the message so it doesn't flash.
- const hideTopMessage = sortOrder === LogsSortOrder.Descending && isRelativeTime(range.raw.to);
- const hideBottomMessage = sortOrder === LogsSortOrder.Ascending && isRelativeTime(range.raw.to);
+ const hideTopMessage = sortOrder === LogsSortOrder.Descending && rangeUtil.isRelativeTime(range.raw.to);
+ const hideBottomMessage = sortOrder === LogsSortOrder.Ascending && rangeUtil.isRelativeTime(range.raw.to);
const loadOlderLogs = useCallback(() => {
//If we are not on the last page, use next page's range
@@ -345,5 +345,7 @@ export function canScrollBottom(
// Given a TimeRange, returns a new instance if using relative time, or else the same.
function updateCurrentRange(timeRange: TimeRange, timeZone: TimeZone) {
- return isRelativeTimeRange(timeRange.raw) ? convertRawToRange(timeRange.raw, timeZone) : timeRange;
+ return rangeUtil.isRelativeTimeRange(timeRange.raw)
+ ? rangeUtil.convertRawToRange(timeRange.raw, timeZone)
+ : timeRange;
}
diff --git a/public/app/features/logs/components/LogDetailsRow.test.tsx b/public/app/features/logs/components/LogDetailsRow.test.tsx
index 5f3229255b3..4463adde1f7 100644
--- a/public/app/features/logs/components/LogDetailsRow.test.tsx
+++ b/public/app/features/logs/components/LogDetailsRow.test.tsx
@@ -1,8 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ComponentProps } from 'react';
-import { CoreApp, FieldType, LinkModel } from '@grafana/data';
-import { Field } from '@grafana/data/';
+import { Field, CoreApp, FieldType, LinkModel } from '@grafana/data';
import { LogDetailsRow } from './LogDetailsRow';
import { createLogRow } from './__mocks__/logRow';
diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts
index 60aef4364bd..a63e464c62f 100644
--- a/public/app/features/logs/logsModel.ts
+++ b/public/app/features/logs/logsModel.ts
@@ -39,7 +39,7 @@ import {
toDataFrame,
toUtc,
} from '@grafana/data';
-import { SIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters';
+import { SIPrefix } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema';
import { colors } from '@grafana/ui';
diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx
index 8e3766ce457..dabaa6d3473 100644
--- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx
+++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
+import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ConfirmModal, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
diff --git a/public/app/features/panel/state/actions.test.ts b/public/app/features/panel/state/actions.test.ts
index 0f6dc41284d..98c0105f848 100644
--- a/public/app/features/panel/state/actions.test.ts
+++ b/public/app/features/panel/state/actions.test.ts
@@ -1,6 +1,5 @@
import { standardEditorsRegistry, standardFieldConfigEditorRegistry } from '@grafana/data';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
-import { mockStandardFieldConfigOptions } from '@grafana/data/test/helpers/fieldConfig';
+import { getPanelPlugin, mockStandardFieldConfigOptions } from '@grafana/data/test';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { panelPluginLoaded } from 'app/features/plugins/admin/state/actions';
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
index 37c484caf1c..9bfec128241 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
@@ -71,12 +71,16 @@ const mockPlugin: CatalogPlugin = {
url: 'https://github.com/grafana/test-plugin/issues/new',
},
],
+ raiseAnIssueUrl: 'https://github.com/grafana/test-plugin/issues/new',
+ documentationUrl: 'https://test-plugin.com/docs',
+ licenseUrl: 'https://github.com/grafana/test-plugin/blob/main/LICENSE',
grafanaDependency: '>=9.0.0',
statusContext: 'stable',
},
angularDetected: false,
isFullyInstalled: true,
accessControl: {},
+ url: 'https://github.com/grafana/test-plugin',
};
const mockInfo = [
@@ -137,10 +141,11 @@ describe('PluginDetailsPanel', () => {
it('should render license, documentation, repository, raise issue links', () => {
render( );
- const repositoryLink = screen.getByText('Repository');
- const licenseLink = screen.getByText('License');
- const documentationLink = screen.getByText('Documentation');
- const raiseIssueLink = screen.getByText('Raise issue');
+ const repositoryLink = screen.getByTestId('plugin-details-repository-link');
+ const licenseLink = screen.getByTestId('plugin-details-license-link');
+ const documentationLink = screen.getByTestId('plugin-details-documentation-link');
+ const raiseIssueLink = screen.getByTestId('plugin-details-raise-issue-link');
+
expect(repositoryLink).toBeInTheDocument();
expect(repositoryLink).toHaveAttribute('href', 'https://github.com/grafana/test-plugin');
expect(licenseLink).toBeInTheDocument();
@@ -150,4 +155,29 @@ describe('PluginDetailsPanel', () => {
expect(raiseIssueLink).toBeInTheDocument();
expect(raiseIssueLink).toHaveAttribute('href', 'https://github.com/grafana/test-plugin/issues/new');
});
+
+ it('should not render license, documentation, repository, raise issue links in custom links', () => {
+ render( );
+ const repositoryLink = screen.getByTestId('plugin-details-repository-link');
+ const licenseLink = screen.getByTestId('plugin-details-license-link');
+ const documentationLink = screen.getByTestId('plugin-details-documentation-link');
+ const raiseIssueLink = screen.getByTestId('plugin-details-raise-issue-link');
+ const websiteLink = screen.getByText('Website');
+
+ const customLinks = screen.getByTestId('plugin-details-custom-links');
+
+ expect(customLinks).not.toContainElement(repositoryLink);
+ expect(customLinks).not.toContainElement(licenseLink);
+ expect(customLinks).not.toContainElement(documentationLink);
+ expect(customLinks).not.toContainElement(raiseIssueLink);
+ expect(customLinks).toContainElement(websiteLink);
+
+ const regularLinks = screen.getByTestId('plugin-details-regular-links');
+
+ expect(regularLinks).toContainElement(repositoryLink);
+ expect(regularLinks).toContainElement(licenseLink);
+ expect(regularLinks).toContainElement(documentationLink);
+ expect(regularLinks).toContainElement(raiseIssueLink);
+ expect(regularLinks).not.toContainElement(websiteLink);
+ });
});
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
index 8440b37c76d..1421245d721 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
@@ -35,12 +35,18 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, '');
const customLinks = plugin.details?.links?.filter((link) => {
- const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl]
+ const customLinksFiltered = ![
+ plugin.url,
+ plugin.details?.licenseUrl,
+ plugin.details?.documentationUrl,
+ plugin.details?.raiseAnIssueUrl,
+ ]
.map(normalizeURL)
.includes(normalizeURL(link.url));
return customLinksFiltered;
});
- const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl;
+ const shouldRenderLinks =
+ plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl || plugin.details?.raiseAnIssueUrl;
const styles = useStyles2(getStyles);
@@ -92,10 +98,17 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
{shouldRenderLinks && (
<>
-
+
{plugin.url && (
-
+
Repository
)}
@@ -106,6 +119,7 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
fill="solid"
icon="bug"
target="_blank"
+ data-testid="plugin-details-raise-issue-link"
>
Raise an issue
@@ -117,6 +131,7 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
fill="solid"
icon={'document-info'}
target="_blank"
+ data-testid="plugin-details-license-link"
>
License
@@ -128,6 +143,7 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
fill="solid"
icon={'list-ui-alt'}
target="_blank"
+ data-testid="plugin-details-documentation-link"
>
Documentation
@@ -137,7 +153,7 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
>
)}
{customLinks && customLinks?.length > 0 && (
-
+
import('@emotion/css'),
'@emotion/react': () => import('@emotion/react'),
'@grafana/data': grafanaData,
- '@grafana/data/unstable': () => import('@grafana/data/src/unstable'),
+ '@grafana/data/unstable': () => import('@grafana/data/unstable'),
'@grafana/runtime': grafanaRuntime,
'@grafana/runtime/unstable': () => import('@grafana/runtime/src/unstable'),
'@grafana/slate-react': () => import('slate-react'),
diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts
index 8b7a85759be..43b58f2d08e 100644
--- a/public/app/features/plugins/pluginPreloader.ts
+++ b/public/app/features/plugins/pluginPreloader.ts
@@ -1,5 +1,8 @@
-import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
-import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
+import type {
+ PluginExtensionAddedLinkConfig,
+ PluginExtensionExposedComponentConfig,
+ PluginExtensionAddedComponentConfig,
+} from '@grafana/data';
import type { AppPluginConfig } from '@grafana/runtime';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
diff --git a/public/app/features/profile/UserProfileEditForm.tsx b/public/app/features/profile/UserProfileEditForm.tsx
index f942b6876fc..0c788577731 100644
--- a/public/app/features/profile/UserProfileEditForm.tsx
+++ b/public/app/features/profile/UserProfileEditForm.tsx
@@ -22,7 +22,10 @@ export const UserProfileEditForm = ({ user, isSavingUser, updateProfile }: Props
// check if authLabels is longer than 0 otherwise false
const isExternalUser: boolean = (user && user.isExternal) ?? false;
- const authSource = isExternalUser && user && user.authLabels ? user.authLabels[0] : '';
+ let authSource = isExternalUser && user && user.authLabels ? user.authLabels[0] : '';
+ if (user?.isProvisioned) {
+ authSource = 'SCIM';
+ }
const lockMessage = authSource ? ` (Synced via ${authSource})` : '';
const disabledEdit = disableLoginForm || isExternalUser;
diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx
new file mode 100644
index 00000000000..138fa353b27
--- /dev/null
+++ b/public/app/features/provisioning/Config/ConfigForm.tsx
@@ -0,0 +1,264 @@
+import { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import {
+ Button,
+ Combobox,
+ ComboboxOption,
+ ControlledCollapse,
+ Field,
+ Input,
+ MultiCombobox,
+ RadioButtonGroup,
+ SecretInput,
+ Stack,
+ Switch,
+} from '@grafana/ui';
+import { Repository, RepositorySpec } from 'app/api/clients/provisioning';
+import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
+
+import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
+import { useCreateOrUpdateRepository } from '../hooks';
+import { RepositoryFormData, WorkflowOption } from '../types';
+import { dataToSpec, specToData } from '../utils/data';
+
+import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse';
+
+const typeOptions = ['GitHub', 'Local'].map((label) => ({ label, value: label.toLowerCase() }));
+const targetOptions = [
+ { value: 'instance', label: 'Entire instance' },
+ { value: 'folder', label: 'Managed folder' },
+];
+
+export function getWorkflowOptions(type?: 'github' | 'local'): Array> {
+ const opts: Array> = [
+ { label: 'Branch', value: 'branch', description: 'Create a branch (and pull request) for changes' },
+ { label: 'Write', value: 'write', description: 'Allow writing updates to the remote repository' },
+ ];
+ if (type === 'github') {
+ return opts;
+ }
+ return opts.filter((opt) => opt.value === 'write'); // only write
+}
+
+const appEvents = getAppEvents();
+
+export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData {
+ if (!repository) {
+ return {
+ type: 'github',
+ title: 'Repository',
+ token: '',
+ url: '',
+ branch: 'main',
+ generateDashboardPreviews: true,
+ workflows: ['branch', 'write'],
+ path: 'grafana/',
+ sync: {
+ enabled: false,
+ target: 'folder',
+ intervalSeconds: 60,
+ },
+ };
+ }
+ return specToData(repository);
+}
+
+export interface ConfigFormProps {
+ data?: Repository;
+}
+export function ConfigForm({ data }: ConfigFormProps) {
+ const [submitData, request] = useCreateOrUpdateRepository(data?.metadata?.name);
+ const {
+ register,
+ handleSubmit,
+ reset,
+ control,
+ formState: { errors, isDirty },
+ setValue,
+ watch,
+ getValues,
+ } = useForm({ defaultValues: getDefaultValues(data?.spec) });
+ const isEdit = Boolean(data?.metadata?.name);
+ const [tokenConfigured, setTokenConfigured] = useState(isEdit);
+ const navigate = useNavigate();
+ const type = watch('type');
+
+ useEffect(() => {
+ if (request.isSuccess) {
+ const formData = getValues();
+
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Repository settings saved'],
+ });
+ reset(formData);
+ setTimeout(() => {
+ navigate('/admin/provisioning');
+ }, 300);
+ }
+ }, [request.isSuccess, reset, getValues, navigate]);
+
+ const onSubmit = (form: RepositoryFormData) => {
+ const spec = dataToSpec(form);
+ if (spec.github) {
+ spec.github.token = form.token || data?.spec?.github?.token;
+ // If we're still keeping this as GitHub, persist the old token. If we set a new one, it'll be re-encrypted into here.
+ spec.github.encryptedToken = data?.spec?.github?.encryptedToken;
+ }
+ submitData(spec);
+ };
+
+ // NOTE: We do not want the lint option to be listed.
+ return (
+
+ );
+}
diff --git a/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx
new file mode 100644
index 00000000000..cb5109b7015
--- /dev/null
+++ b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx
@@ -0,0 +1,63 @@
+import { ReactElement } from 'react';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { config } from '@grafana/runtime';
+import { Alert, ControlledCollapse, Field } from '@grafana/ui';
+
+import { checkPublicAccess } from '../GettingStarted/features';
+import { GETTING_STARTED_URL } from '../constants';
+
+export interface ConfigFormGithubCollapseProps {
+ previews: ReactElement;
+}
+export function ConfigFormGithubCollapse({ previews }: ConfigFormGithubCollapseProps) {
+ const navigate = useNavigate();
+
+ return (
+
+ Realtime feedback
+ {checkPublicAccess() ? (
+
+
+ Changes in git will be quickly pulled into grafana. Pull requests can be processed.
+
+
+ ) : (
+ Instructions}
+ onRemove={() => navigate(GETTING_STARTED_URL)}
+ >
+ Changes in git will eventually be pulled depending on the synchronization interval. Pull requests will not be
+ processed
+
+ )}
+
+ Pull Request image previews
+ {!config.rendererAvailable && (
+ Instructions}
+ onRemove={() => window.open('https://grafana.com/grafana/plugins/grafana-image-renderer/', '_blank')}
+ >
+ When the image renderer is configured, pull requests can see preview images
+
+ )}
+
+
+ Render before/after images and link them to the pull request.
+
+ NOTE! this will render dashboards into an image that can be access by a public URL
+
+ }
+ >
+ {previews}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/File/FileHistoryPage.tsx b/public/app/features/provisioning/File/FileHistoryPage.tsx
new file mode 100644
index 00000000000..a91b733076b
--- /dev/null
+++ b/public/app/features/provisioning/File/FileHistoryPage.tsx
@@ -0,0 +1,84 @@
+import { useParams } from 'react-router-dom-v5-compat';
+
+import { Card, EmptyState, Spinner, Stack, Text, TextLink, UserIcon } from '@grafana/ui';
+import { useGetRepositoryHistoryWithPathQuery, useGetRepositoryStatusQuery } from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+import { isNotFoundError } from 'app/features/alerting/unified/api/util';
+
+import { PROVISIONING_URL } from '../constants';
+import { HistoryListResponse } from '../types';
+import { formatTimestamp } from '../utils/time';
+
+export default function FileHistoryPage() {
+ const params = useParams();
+ const name = params['name'] ?? '';
+ const path = params['*'] ?? '';
+ const query = useGetRepositoryStatusQuery({ name });
+ const history = useGetRepositoryHistoryWithPathQuery({ name, path });
+ const notFound = query.isError && isNotFoundError(query.error);
+
+ return (
+
+
+ {notFound ? (
+
+ Make sure the repository config exists in the configuration file.
+ Back to repositories
+
+ ) : (
+ //@ts-expect-error TODO fix history response types
+ {history.data ? : }
+ )}
+
+
+ );
+}
+
+interface Props {
+ history: HistoryListResponse;
+ path: string;
+ repo: string;
+}
+
+function HistoryView({ history, path, repo }: Props) {
+ if (!history.items) {
+ return not found
;
+ }
+
+ return (
+
+ {history.items.map((item) => (
+
+ {item.message}
+
+ {formatTimestamp(item.createdAt)}
+
+
+
+ {item.authors.map((a) => (
+
+ {a.avatarURL && (
+
+ )}
+ {a.name}
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
diff --git a/public/app/features/provisioning/File/FileStatusPage.tsx b/public/app/features/provisioning/File/FileStatusPage.tsx
new file mode 100644
index 00000000000..c7edb58ed3a
--- /dev/null
+++ b/public/app/features/provisioning/File/FileStatusPage.tsx
@@ -0,0 +1,182 @@
+import { useEffect, useState } from 'react';
+import { useLocation } from 'react-router';
+import { useParams } from 'react-router-dom-v5-compat';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+import { urlUtil } from '@grafana/data';
+import { isFetchError } from '@grafana/runtime';
+import { Alert, CodeEditor, LinkButton, Button, Stack, Tab, TabContent, TabsBar, DeleteButton } from '@grafana/ui';
+import {
+ useGetRepositoryFilesWithPathQuery,
+ ResourceWrapper,
+ useReplaceRepositoryFilesWithPathMutation,
+ useDeleteRepositoryFilesWithPathMutation,
+} from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+import { useQueryParams } from 'app/core/hooks/useQueryParams';
+
+import { PROVISIONING_URL } from '../constants';
+
+export default function FileStatusPage() {
+ const params = useParams();
+ const [queryParams] = useQueryParams();
+ const ref = (queryParams['ref'] as string) ?? undefined;
+ const tab = (queryParams['tab'] as TabSelection) ?? TabSelection.File;
+ const name = params['name'] ?? '';
+ const path = params['*'] ?? '';
+ const file = useGetRepositoryFilesWithPathQuery({ name, path, ref });
+
+ return (
+
+
+ <>
+ {isFetchError(file.error) && {file.error.message} }
+ {file.isSuccess && file.data && }
+ >
+
+
+ );
+}
+
+enum TabSelection {
+ File = 'file',
+ Existing = 'existing',
+ DryRun = 'dryRun',
+}
+
+interface Props {
+ wrap: ResourceWrapper;
+ repo: string;
+ repoRef?: string;
+ tab: TabSelection;
+}
+
+function ResourceView({ wrap, repo, repoRef, tab }: Props) {
+ const isDashboard = wrap.resource?.type?.kind === 'Dashboard';
+ const existingName = wrap.resource?.existing?.metadata?.name;
+ const location = useLocation();
+ const [queryParams] = useQueryParams();
+ const [replaceFile, replaceFileStatus] = useReplaceRepositoryFilesWithPathMutation();
+ const [deleteFile, deleteFileStatus] = useDeleteRepositoryFilesWithPathMutation();
+
+ const [jsonView, setJsonView] = useState('');
+
+ useEffect(() => {
+ switch (tab) {
+ case TabSelection.Existing:
+ setJsonView(JSON.stringify(wrap.resource.existing, null, 2));
+ return;
+ case TabSelection.DryRun:
+ setJsonView(JSON.stringify(wrap.resource.dryRun, null, 2));
+ return;
+ case TabSelection.File:
+ setJsonView(JSON.stringify(wrap.resource.file, null, 2));
+ return;
+ }
+ }, [wrap, tab, setJsonView]);
+
+ const tabInfo = [
+ { value: TabSelection.File, label: 'File (from repository)' },
+ { value: TabSelection.Existing, label: 'Existing (from grafana)' },
+ { value: TabSelection.DryRun, label: 'Dry Run (result after apply)' },
+ ];
+
+ return (
+
+
+ {isDashboard && (
+
+ Dashboard Preview
+
+ )}
+ {isDashboard && existingName && (
+
+ Existing dashboard
+
+ )}
+
+ Repository
+
+ {repoRef && (
+
+ Base
+
+ )}
+
+ History
+
+
+
+
+
+
+
+ {tabInfo.map((t) => (
+
+ ))}
+
+
+
+
+
+ {({ height }) => (
+
+ )}
+
+
+
+ {
+ replaceFile({
+ name: repo,
+ path: wrap.path!,
+ body: JSON.parse(jsonView),
+ message: 'updated from repo test UI',
+ });
+ }}
+ >
+ {replaceFileStatus.isLoading ? 'Saving' : 'Save'}
+
+ {
+ deleteFile({
+ name: repo,
+ path: wrap.path!,
+ message: 'removed from repo test UI',
+ });
+ }}
+ />
+
+ {replaceFileStatus.isError && (
+
+ {JSON.stringify(replaceFileStatus.error)}
+
+ )}
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/File/FilesView.tsx b/public/app/features/provisioning/File/FilesView.tsx
new file mode 100644
index 00000000000..93cf588241e
--- /dev/null
+++ b/public/app/features/provisioning/File/FilesView.tsx
@@ -0,0 +1,80 @@
+import { useState } from 'react';
+
+import { CellProps, Column, FilterInput, InteractiveTable, LinkButton, Spinner, Stack } from '@grafana/ui';
+import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning';
+
+import { PROVISIONING_URL } from '../constants';
+import { FileDetails } from '../types';
+
+interface FilesViewProps {
+ repo: Repository;
+}
+
+type FileCell = CellProps;
+
+export function FilesView({ repo }: FilesViewProps) {
+ const name = repo.metadata?.name ?? '';
+ const query = useGetRepositoryFilesQuery({ name });
+ const [searchQuery, setSearchQuery] = useState('');
+ const data = [...(query.data?.items ?? [])].filter((file) =>
+ file.path.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const columns: Array> = [
+ {
+ id: 'path',
+ header: 'Path',
+ sortType: 'string',
+ cell: ({ row: { original } }: FileCell<'path'>) => {
+ const { path } = original;
+ return {path} ;
+ },
+ },
+ {
+ id: 'size',
+ header: 'Size (KB)',
+ cell: ({ row: { original } }: FileCell<'size'>) => {
+ const { size } = original;
+ return (parseInt(size, 10) / 1024).toFixed(2);
+ },
+ sortType: 'number',
+ },
+ {
+ id: 'hash',
+ header: 'Hash',
+ sortType: 'string',
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row: { original } }: FileCell<'path'>) => {
+ const { path } = original;
+ return (
+
+ {(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && (
+ View
+ )}
+ History
+
+ );
+ },
+ },
+ ];
+
+ if (query.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ String(f.path)} />
+
+ );
+}
diff --git a/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx b/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx
new file mode 100644
index 00000000000..a772158998b
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx
@@ -0,0 +1,89 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Box, Stack, Text, LinkButton, Icon, IconName, useStyles2 } from '@grafana/ui';
+
+import { FeatureCard } from './FeatureCard';
+
+interface IconCircleProps {
+ icon: string;
+ color: string;
+ background: string;
+}
+
+const IconCircle = ({ icon, color, background }: IconCircleProps) => (
+
+
+
+);
+
+interface EnhancedFeaturesProps {
+ hasPublicAccess: boolean;
+ hasImageRenderer: boolean;
+ onSetupPublicAccess: () => void;
+}
+
+export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPublicAccess }: EnhancedFeaturesProps) => {
+ const style = useStyles2(getStyles);
+
+ return (
+
+ Unlock enhanced functionality for GitHub
+
+
+ }
+ action={
+ !hasPublicAccess && (
+
+ Set up public access
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+ }
+ action={
+ !hasImageRenderer && (
+
+ Set up image rendering
+
+ )
+ }
+ />
+
+
+
+ );
+};
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ separator: css({
+ borderRight: `2px solid ${theme.colors.border.weak}`,
+ }),
+ };
+}
diff --git a/public/app/features/provisioning/GettingStarted/FeatureCard.tsx b/public/app/features/provisioning/GettingStarted/FeatureCard.tsx
new file mode 100644
index 00000000000..b70ad55d880
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/FeatureCard.tsx
@@ -0,0 +1,21 @@
+import { Box, Stack, Text } from '@grafana/ui';
+
+interface FeatureCardProps {
+ title: string;
+ description: string;
+ icon?: React.ReactNode;
+ action?: React.ReactNode;
+}
+
+export const FeatureCard = ({ title, description, icon, action }: FeatureCardProps) => (
+
+
+
+ {icon}
+ {title}
+ {description}
+ {action && {action} }
+
+
+
+);
diff --git a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx
new file mode 100644
index 00000000000..8e66c9b4f77
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx
@@ -0,0 +1,80 @@
+import { Stack, Text, Box, LinkButton, Icon } from '@grafana/ui';
+import { Repository } from 'app/api/clients/provisioning';
+
+import { ConnectRepositoryButton } from '../Shared/ConnectRepositoryButton';
+
+interface FeatureItemProps {
+ children: NonNullable;
+}
+
+const FeatureItem = ({ children }: FeatureItemProps) => {
+ // We use a stack here to ensure the icon and text are aligned correctly.
+ return (
+
+
+ {children}
+
+ );
+};
+
+interface FeaturesListProps {
+ repos?: Repository[];
+ hasPublicAccess: boolean;
+ hasImageRenderer: boolean;
+ hasRequiredFeatures: boolean;
+ onSetupFeatures: () => void;
+}
+
+export const FeaturesList = ({
+ repos,
+ hasPublicAccess,
+ hasImageRenderer,
+ hasRequiredFeatures,
+ onSetupFeatures,
+}: FeaturesListProps) => {
+ const actions = () => {
+ if (!hasRequiredFeatures) {
+ return (
+
+
+ Set up required feature toggles
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ Manage your dashboards with remote provisioning
+ Manage dashboards as code and provision updates automatically
+
+ Store dashboards in version-controlled storage for better organization and history tracking
+
+ Migrate existing dashboards to storage for provisioning
+ {hasPublicAccess && (
+
+ Automatically provision and update your dashboards as soon as changes are pushed to your GitHub repository
+
+ )}
+ {hasImageRenderer && hasPublicAccess && (
+ Visual previews in pull requests to review your changes before going live
+ )}
+
+ {false && (
+ // We haven't gotten the design for this quite yet.
+
+ Learn more
+
+ )}
+
+ {actions()}
+
+ );
+};
diff --git a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx
new file mode 100644
index 00000000000..c8a903b423a
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx
@@ -0,0 +1,160 @@
+import { css } from '@emotion/css';
+import { useState } from 'react';
+
+import { Alert, Stack, Text, Box } from '@grafana/ui';
+import { useGetFrontendSettingsQuery, Repository } from 'app/api/clients/provisioning';
+
+import { EnhancedFeatures } from './EnhancedFeatures';
+import { FeaturesList } from './FeaturesList';
+import { SetupModal } from './SetupModal';
+import { getConfigurationStatus } from './features';
+
+type SetupType = 'public-access' | 'required-features' | null;
+
+// Configuration examples
+const featureIni = `# In your custom.ini file
+app_mode = development
+
+[feature_toggles]
+provisioning = true
+kubernetesDashboards = true
+unifiedStorageSearch = true
+kubernetesClientDashboardsFolders = true
+
+# If you want easy kubectl setup development mode
+grafanaAPIServerEnsureKubectlAccess = true`;
+
+const ngrokExample = `ngrok http 3000
+
+Help shape K8s Bindings https://ngrok.com/new-features-update?ref=k8s
+
+Session Status online
+Account Roberto Jiménez Sánchez (Plan: Free)
+Version 3.18.4
+Region Europe (eu)
+Latency 44ms
+Web Interface http://127.0.0.1:4040
+Forwarding https://d60d-83-33-235-27.ngrok-free.app -> http://localhost:3000
+Connections ttl opn rt1 rt5 p50 p90
+ 50 2 0.00 0.00 83.03 90.56
+
+HTTP Requests
+-------------
+
+09:18:46.147 CET GET /favicon.ico 302 Found
+09:18:46.402 CET GET /login`;
+
+const rootUrlExample = `[server]
+root_url = https://d60d-83-33-235-27.ngrok-free.app`;
+
+interface Props {
+ items: Repository[];
+}
+
+export default function GettingStarted({ items }: Props) {
+ const settingsQuery = useGetFrontendSettingsQuery();
+ const legacyStorage = settingsQuery.data?.legacyStorage;
+
+ const { hasPublicAccess, hasImageRenderer, hasRequiredFeatures } = getConfigurationStatus();
+ const [showInstructionsModal, setShowModal] = useState(false);
+ const [setupType, setSetupType] = useState(null);
+
+ const getModalContent = () => {
+ switch (setupType) {
+ case 'public-access':
+ return {
+ title: 'Set up public access',
+ description: 'Set up public access to your Grafana instance to enable GitHub integration',
+ steps: [
+ {
+ title: 'Start ngrok for temporary public access',
+ description: 'Run this command to create a secure tunnel to your local Grafana:',
+ code: 'ngrok http 3000',
+ },
+ {
+ title: 'Copy your public URL',
+ description: 'From the ngrok output, copy the https:// forwarding URL that looks like this:',
+ code: ngrokExample,
+ copyCode: false,
+ },
+ {
+ title: 'Update your Grafana configuration',
+ description: 'Add this to your custom.ini file, replacing the URL with your actual ngrok URL:',
+ code: rootUrlExample,
+ },
+ ],
+ };
+ case 'required-features':
+ return {
+ title: 'Set up required features',
+ description: 'Enable required Grafana features for provisioning',
+ steps: [
+ {
+ title: 'Enable Required Feature Toggles',
+ description: 'Add these settings to your custom.ini file to enable necessary features:',
+ code: featureIni,
+ },
+ ],
+ };
+ default:
+ return {
+ title: '',
+ description: '',
+ steps: [],
+ };
+ }
+ };
+
+ return (
+ <>
+ {legacyStorage && (
+
+ When you connect your whole instance, dashboards will be unavailable while running the migration. We recommend
+ warning your users before starting the process.
+
+ )}
+
+
+ {
+ setSetupType('required-features');
+ setShowModal(true);
+ }}
+ />
+
+
+
+ Engaging graphic
+
+
+
+ {(!hasPublicAccess || !hasImageRenderer) && (
+ {
+ setSetupType('public-access');
+ setShowModal(true);
+ }}
+ />
+ )}
+ {showInstructionsModal && setupType && (
+ setShowModal(false)} />
+ )}
+ >
+ );
+}
diff --git a/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx
new file mode 100644
index 00000000000..bbb0c40c885
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx
@@ -0,0 +1,24 @@
+import { Repository } from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+
+import GettingStarted from './GettingStarted';
+interface Props {
+ items: Repository[];
+}
+
+export default function GettingStartedPage({ items }: Props) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/GettingStarted/SetupModal.tsx b/public/app/features/provisioning/GettingStarted/SetupModal.tsx
new file mode 100644
index 00000000000..d9eadb7b5a0
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/SetupModal.tsx
@@ -0,0 +1,89 @@
+import { css } from '@emotion/css';
+import { useState } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Modal, Button, useStyles2, Stack, Text } from '@grafana/ui';
+
+import { SetupStep } from './SetupStep';
+import { Sidebar } from './Sidebar';
+import { Step } from './types';
+
+export interface Props {
+ title: string;
+ description: string;
+ steps: Step[];
+
+ isOpen: boolean;
+ onDismiss: () => void;
+}
+
+export const SetupModal = ({ title, description, steps, isOpen, onDismiss }: Props) => {
+ const styles = useStyles2(getStyles);
+
+ const [currentStep, setCurrentStep] = useState(0);
+
+ const isFirstStep = currentStep === 0;
+ const isLastStep = currentStep === steps.length - 1;
+ const stepTitles = steps.map((step) => step.title);
+
+ const handleNext = () => !isLastStep && setCurrentStep(currentStep + 1);
+ const handlePrevious = () => !isFirstStep && setCurrentStep(currentStep - 1);
+
+ return (
+
+
+
+ {description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Previous
+
+
+ {isLastStep ? (
+
+ Done
+
+ ) : (
+
+ Next
+
+ )}
+
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ modal: css({
+ width: '1100px',
+ maxWidth: '95%',
+ }),
+ description: css({
+ marginBottom: theme.spacing(3),
+ padding: theme.spacing(0, 1),
+ }),
+ contentWrapper: css({
+ flex: 1,
+ overflowY: 'auto',
+ minWidth: 0,
+ padding: theme.spacing(2),
+ borderLeft: `1px solid ${theme.colors.border.weak}`,
+ }),
+ footer: css({
+ padding: theme.spacing(2),
+ borderTop: `1px solid ${theme.colors.border.weak}`,
+ marginTop: theme.spacing(2),
+ }),
+});
diff --git a/public/app/features/provisioning/GettingStarted/SetupStep.tsx b/public/app/features/provisioning/GettingStarted/SetupStep.tsx
new file mode 100644
index 00000000000..e52a4299324
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/SetupStep.tsx
@@ -0,0 +1,26 @@
+import { Container, Text } from '@grafana/ui';
+
+import { CodeBlock } from '../Shared/CodeBlock';
+
+import { Step } from './types';
+
+export interface Props {
+ step: Step;
+}
+
+export const SetupStep = ({ step }: Props) => {
+ return (
+ <>
+
+ {step?.title}
+
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+ {step.code && }
+ >
+ );
+};
diff --git a/public/app/features/provisioning/GettingStarted/Sidebar.tsx b/public/app/features/provisioning/GettingStarted/Sidebar.tsx
new file mode 100644
index 00000000000..7e53b7242b1
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/Sidebar.tsx
@@ -0,0 +1,34 @@
+import { useStyles2, Stack, Box } from '@grafana/ui';
+
+import { SidebarItem, getStyles as getStepItemStyles } from './SidebarItem';
+
+interface Props {
+ steps: string[];
+ currentStep: number;
+ onStepClick: (index: number) => void;
+}
+
+export const Sidebar = ({ steps, currentStep, onStepClick }: Props) => {
+ if (steps.length === 0 || steps.length === 1) {
+ return null;
+ }
+
+ const stepItemStyles = useStyles2(getStepItemStyles);
+
+ return (
+
+
+ {steps.map((step, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/public/app/features/provisioning/GettingStarted/SidebarItem.tsx b/public/app/features/provisioning/GettingStarted/SidebarItem.tsx
new file mode 100644
index 00000000000..ed352b80e04
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/SidebarItem.tsx
@@ -0,0 +1,75 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { IconButton, Text, Stack, Card } from '@grafana/ui';
+
+export interface Props {
+ step: string;
+ index: number;
+ currentStep: number;
+ onStepClick: (index: number) => void;
+ styles: ReturnType;
+}
+
+export const SidebarItem = ({ step, index, currentStep, onStepClick, styles }: Props) => {
+ const isCompleted = index < currentStep;
+ const isCurrent = index === currentStep;
+ const isPending = index > currentStep;
+
+ const getStepStatus = () => {
+ if (isCompleted) {
+ return { icon: 'check-circle' as const, color: 'success', label: 'Completed step' };
+ }
+ if (isCurrent) {
+ return { icon: 'circle' as const, color: 'primary', label: 'Current step' };
+ }
+ return { icon: 'circle' as const, color: 'secondary', label: 'Pending step' };
+ };
+
+ const { icon, color, label } = getStepStatus();
+
+ const handleClick = () => onStepClick(index);
+ const handleIconClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onStepClick(index);
+ };
+
+ return (
+
+
+
+
+ {step}
+
+
+
+ );
+};
+
+export const getStyles = (theme: GrafanaTheme2) => ({
+ stepItem: css({
+ padding: theme.spacing(1),
+ cursor: 'pointer',
+ '&:hover': {
+ background: theme.colors.action.hover,
+ },
+ }),
+ activeStep: css({
+ color: theme.colors.primary.text,
+ }),
+ plainCard: css({
+ background: 'transparent',
+ border: 'none',
+ boxShadow: 'none',
+ }),
+});
diff --git a/public/app/features/provisioning/GettingStarted/features.ts b/public/app/features/provisioning/GettingStarted/features.ts
new file mode 100644
index 00000000000..80bb1ef3c88
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/features.ts
@@ -0,0 +1,53 @@
+import { FeatureToggles } from '@grafana/data';
+import { config } from '@grafana/runtime';
+
+export const requiredFeatureToggles: Array = [
+ 'provisioning',
+ 'kubernetesDashboards',
+ 'kubernetesClientDashboardsFolders',
+ 'unifiedStorageSearch',
+];
+
+/**
+ * Checks if all required feature toggles are enabled
+ * @returns true if all required feature toggles are enabled
+ */
+export const checkRequiredFeatures = (): boolean => {
+ const featureToggles = config.featureToggles || {};
+ return requiredFeatureToggles.every((toggle) => featureToggles[toggle]);
+};
+
+/**
+ * Checks if public access is configured
+ * @returns true if the app URL is configured for external access
+ */
+export const checkPublicAccess = (): boolean => {
+ return Boolean(config.appUrl && config.appUrl.indexOf('://localhost') < 0);
+};
+
+/**
+ * Checks if image renderer is configured
+ * @returns true if the image renderer is available
+ */
+export const checkImageRenderer = (): boolean => {
+ return Boolean(config.rendererAvailable);
+};
+
+/**
+ * Returns the configuration status of all features
+ * @returns Object containing the status of required and optional features
+ */
+export const getConfigurationStatus = () => {
+ const hasRequiredFeatures = checkRequiredFeatures();
+ const hasPublicAccess = checkPublicAccess();
+ const hasImageRenderer = checkImageRenderer();
+
+ return {
+ hasRequiredFeatures,
+ hasPublicAccess,
+ hasImageRenderer,
+ missingOnlyOptionalFeatures: hasRequiredFeatures && (!hasPublicAccess || !hasImageRenderer),
+ missingRequiredFeatures: !hasRequiredFeatures,
+ everythingConfigured: hasRequiredFeatures && hasPublicAccess && hasImageRenderer,
+ };
+};
diff --git a/public/app/features/provisioning/GettingStarted/types.ts b/public/app/features/provisioning/GettingStarted/types.ts
new file mode 100644
index 00000000000..4df5ccf2c79
--- /dev/null
+++ b/public/app/features/provisioning/GettingStarted/types.ts
@@ -0,0 +1,6 @@
+export interface Step {
+ title: string;
+ description?: string;
+ code?: string;
+ copyCode?: boolean;
+}
diff --git a/public/app/features/provisioning/HomePage.tsx b/public/app/features/provisioning/HomePage.tsx
new file mode 100644
index 00000000000..3342081cf53
--- /dev/null
+++ b/public/app/features/provisioning/HomePage.tsx
@@ -0,0 +1,149 @@
+import { useEffect, useState } from 'react';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { Alert, ConfirmModal, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
+import { useDeletecollectionRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+
+import { FilesView } from './File/FilesView';
+import GettingStarted from './GettingStarted/GettingStarted';
+import GettingStartedPage from './GettingStarted/GettingStartedPage';
+import { RepositoryActions } from './Repository/RepositoryActions';
+import { RepositoryOverview } from './Repository/RepositoryOverview';
+import { RepositoryResources } from './Repository/RepositoryResources';
+import { FolderRepositoryList } from './Shared/FolderRepositoryList';
+import { useRepositoryList } from './hooks';
+import { checkSyncSettings } from './utils/checkSyncSettings';
+
+const appEvents = getAppEvents();
+
+enum TabSelection {
+ Overview = 'overview',
+ Resources = 'resources',
+ Files = 'files',
+ GettingStarted = 'getting-started',
+ Repositories = 'repositories',
+}
+
+const connectedTabInfo = [
+ { value: TabSelection.Overview, label: 'Overview', title: 'Repository overview' },
+ { value: TabSelection.Resources, label: 'Resources', title: 'Resources saved in Grafana database' },
+ { value: TabSelection.Files, label: 'Files', title: 'The raw file list from the repository' },
+ { value: TabSelection.GettingStarted, label: 'Getting started', title: 'Getting started' },
+];
+
+const disconnectedTabInfo = [
+ { value: TabSelection.Repositories, label: 'Repositories', title: 'List of repositories' },
+ { value: TabSelection.GettingStarted, label: 'Getting started', title: 'Getting started' },
+];
+
+export default function HomePage() {
+ const [items, isLoading] = useRepositoryList({ watch: true });
+ const settings = useGetFrontendSettingsQuery();
+ const [deleteAll, deleteAllResult] = useDeletecollectionRepositoryMutation();
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const { instanceConnected } = checkSyncSettings(items);
+ const [activeTab, setActiveTab] = useState(
+ instanceConnected ? TabSelection.Overview : TabSelection.Repositories
+ );
+
+ useEffect(() => {
+ if (deleteAllResult.isSuccess) {
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['All configured repositories deleted'],
+ });
+ }
+ }, [deleteAllResult.isSuccess]);
+
+ useEffect(() => {
+ setActiveTab(instanceConnected ? TabSelection.Overview : TabSelection.Repositories);
+ }, [instanceConnected]);
+
+ // Early return for onboarding
+ if (!items?.length && !isLoading) {
+ return ;
+ }
+
+ const onConfirmDelete = () => {
+ deleteAll({});
+ setShowDeleteModal(false);
+ };
+
+ const renderTabContent = () => {
+ if (!instanceConnected) {
+ switch (activeTab) {
+ case TabSelection.Repositories:
+ return ;
+ case TabSelection.GettingStarted:
+ return ;
+ default:
+ return null;
+ }
+ }
+
+ const repo = items?.[0];
+ if (!repo) {
+ return null;
+ }
+
+ switch (activeTab) {
+ case TabSelection.Overview:
+ return ;
+ case TabSelection.Resources:
+ return ;
+ case TabSelection.Files:
+ return ;
+ case TabSelection.GettingStarted:
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+ : undefined}
+ >
+
+ {settings.data?.legacyStorage && (
+ Remove all configured repositories>}
+ onRemove={() => {
+ setShowDeleteModal(true);
+ }}
+ >
+ Configured repositories will not work while running legacy storage.
+
+ )}
+ setShowDeleteModal(false)}
+ />
+
+
+ {(instanceConnected ? connectedTabInfo : disconnectedTabInfo).map((t) => (
+ setActiveTab(t.value)}
+ title={t.title}
+ />
+ ))}
+
+ {renderTabContent()}
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx
new file mode 100644
index 00000000000..ee064c10dc1
--- /dev/null
+++ b/public/app/features/provisioning/Job/JobStatus.tsx
@@ -0,0 +1,144 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useEffect } from 'react';
+
+import { Alert, ControlledCollapse, LinkButton, Spinner, Stack, Text } from '@grafana/ui';
+import { useGetRepositoryQuery } from 'app/api/clients/provisioning';
+
+import ProgressBar from '../Shared/ProgressBar';
+import { useRepositoryAllJobs } from '../hooks/useRepositoryAllJobs';
+import { getRepoHref } from '../utils/git';
+
+import { JobSummary } from './JobSummary';
+
+export interface JobStatusProps {
+ name: string;
+ onStatusChange?: (success: boolean) => void;
+ onRunningChange?: (isRunning: boolean) => void;
+ onErrorChange?: (error: string | null) => void;
+}
+
+export function JobStatus({ name, onStatusChange, onRunningChange, onErrorChange }: JobStatusProps) {
+ const [jobs, activeQuery, historicQuery] = useRepositoryAllJobs({ jobName: name, watch: true });
+ const job = jobs?.[0];
+
+ useEffect(() => {
+ if (onRunningChange) {
+ onRunningChange(
+ activeQuery.isLoading ||
+ historicQuery.isLoading ||
+ !job ||
+ job.status?.state === 'working' ||
+ job.status?.state === 'pending'
+ );
+ }
+ }, [activeQuery.isLoading, historicQuery.isLoading, job, onRunningChange, onErrorChange]);
+
+ useEffect(() => {
+ if (onStatusChange && job?.status?.state === 'success') {
+ onStatusChange(true);
+ if (onRunningChange) {
+ onRunningChange(false);
+ }
+ }
+ if (onErrorChange && job?.status?.state === 'error') {
+ onErrorChange(job.status.message ?? 'An unknown error occurred');
+ if (onRunningChange) {
+ onRunningChange(false);
+ }
+ }
+ }, [job, onStatusChange, onErrorChange, onRunningChange]);
+
+ if (!name || activeQuery.isLoading || historicQuery.isLoading || !job) {
+ return (
+
+
+
+ Starting...
+
+
+ );
+ }
+
+ const status = () => {
+ switch (job.status?.state) {
+ case 'success':
+ return ;
+ case 'error':
+ return (
+
+ {job.status.message}
+
+ );
+ }
+ return (
+
+ {!job.status?.progress && }
+
+ {job.status?.message ?? job.status?.state!}
+
+
+ );
+ };
+
+ return (
+
+ {job.status && (
+
+ {status()}
+
+
+
+
+
+ {job.status.summary && (
+
+ Summary
+
+
+ )}
+ {job.status.state === 'success' ? (
+
+ ) : (
+
+ {JSON.stringify(job, null, ' ')}
+
+ )}
+
+ )}
+
+ );
+}
+
+type RepositoryLinkProps = {
+ name?: string;
+};
+
+function RepositoryLink({ name }: RepositoryLinkProps) {
+ const repoQuery = useGetRepositoryQuery(name ? { name } : skipToken);
+ const repo = repoQuery.data;
+
+ if (!repo || repoQuery.isLoading || repo.spec?.type !== 'github' || !repo.spec?.github?.url) {
+ return null;
+ }
+
+ const repoHref = getRepoHref(repo.spec?.github);
+ const folderHref = repo.spec?.sync.target === 'folder' ? `/dashboards/f/${repo.metadata?.name}` : '/dashboards';
+
+ if (!repoHref) {
+ return null;
+ }
+
+ return (
+
+ Grafana and your repository are now in sync.
+
+
+ View repository
+
+
+ View folder
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Job/JobSummary.tsx b/public/app/features/provisioning/Job/JobSummary.tsx
new file mode 100644
index 00000000000..09cf993efd3
--- /dev/null
+++ b/public/app/features/provisioning/Job/JobSummary.tsx
@@ -0,0 +1,66 @@
+import { InteractiveTable, Stack } from '@grafana/ui';
+import { JobResourceSummary } from 'app/api/clients/provisioning';
+
+type SummaryCell = {
+ row: {
+ original: JobResourceSummary;
+ };
+};
+
+const getSummaryColumns = () => [
+ {
+ id: 'resource',
+ header: 'Resource',
+ cell: ({ row: { original: item } }: SummaryCell) => item.resource,
+ },
+ {
+ id: 'created',
+ header: 'Created',
+ cell: ({ row: { original: item } }: SummaryCell) => item.create?.toString() || '-',
+ },
+ {
+ id: 'deleted',
+ header: 'Deleted',
+ cell: ({ row: { original: item } }: SummaryCell) => item.delete?.toString() || '-',
+ },
+ {
+ id: 'updated',
+ header: 'Updated',
+ cell: ({ row: { original: item } }: SummaryCell) => item.update?.toString() || '-',
+ },
+ {
+ id: 'unchanged',
+ header: 'Unchanged',
+ cell: ({ row: { original: item } }: SummaryCell) => item.noop?.toString() || '-',
+ },
+ {
+ id: 'errors',
+ header: 'Errors',
+ cell: ({ row: { original: item } }: SummaryCell) => item.error?.toString() || '-',
+ },
+ {
+ id: 'total',
+ header: 'Total',
+ cell: ({ row: { original: item } }: SummaryCell) => {
+ const total = (item.create || 0) + (item.delete || 0) + (item.update || 0) + (item.noop || 0) + (item.error || 0);
+ return total.toString();
+ },
+ },
+];
+
+interface Props {
+ summary: JobResourceSummary[];
+}
+
+export function JobSummary({ summary }: Props) {
+ return (
+
+ item.resource || ''}
+ pageSize={10}
+ />
+
+ );
+}
diff --git a/public/app/features/provisioning/Job/RecentJobs.tsx b/public/app/features/provisioning/Job/RecentJobs.tsx
new file mode 100644
index 00000000000..897e5d237b2
--- /dev/null
+++ b/public/app/features/provisioning/Job/RecentJobs.tsx
@@ -0,0 +1,214 @@
+import { useMemo } from 'react';
+
+import { intervalToAbbreviatedDurationString, TraceKeyValuePair } from '@grafana/data';
+import { Alert, Badge, Box, Card, Icon, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui';
+import { HistoricJob, Job, Repository, SyncStatus } from 'app/api/clients/provisioning';
+import KeyValuesTable from 'app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable';
+
+import { useRepositoryAllJobs } from '../hooks/useRepositoryAllJobs';
+import { formatTimestamp } from '../utils/time';
+
+import { JobSummary } from './JobSummary';
+
+interface Props {
+ repo: Repository;
+}
+
+type JobCell = {
+ row: {
+ original: Job | HistoricJob;
+ };
+};
+
+const getStatusColor = (state?: SyncStatus['state']) => {
+ switch (state) {
+ case 'success':
+ return 'green';
+ case 'working':
+ case 'pending':
+ return 'orange';
+ case 'error':
+ return 'red';
+ default:
+ return 'darkgrey';
+ }
+};
+
+const getJobColumns = () => [
+ {
+ id: 'status',
+ header: 'Status',
+ cell: ({ row: { original: job } }: JobCell) => (
+
+ ),
+ },
+ {
+ id: 'action',
+ header: 'Action',
+ cell: ({ row: { original: job } }: JobCell) => job.spec?.action,
+ },
+ {
+ id: 'started',
+ header: 'Started',
+ cell: ({ row: { original: job } }: JobCell) => formatTimestamp(job.status?.started),
+ },
+ {
+ id: 'duration',
+ header: 'Duration',
+ cell: ({ row: { original: job } }: JobCell) => {
+ const interval = {
+ start: job.status?.started ?? 0,
+ end: job.status?.finished ?? Date.now(),
+ };
+ if (!interval.start) {
+ return null;
+ }
+ const elapsed = interval.end - interval.start;
+ if (elapsed < 1000) {
+ return `${elapsed}ms`;
+ }
+ return intervalToAbbreviatedDurationString(interval, true);
+ },
+ },
+ {
+ id: 'message',
+ header: 'Message',
+ cell: ({ row: { original: job } }: JobCell) => {job.status?.message} ,
+ },
+];
+
+interface ExpandedRowProps {
+ row: Job;
+}
+
+function ExpandedRow({ row }: ExpandedRowProps) {
+ const hasSummary = Boolean(row.status?.summary?.length);
+ const hasErrors = Boolean(row.status?.errors?.length);
+ const hasSpec = Boolean(row.spec);
+
+ if (!hasSummary && !hasErrors && !hasSpec) {
+ console.log('no summary, errors, or spec', row);
+ return null;
+ }
+
+ // the action is already showin
+ const data = useMemo(() => {
+ const v: TraceKeyValuePair[] = [];
+ const action = row.spec?.action;
+ if (!action) {
+ return v;
+ }
+ const def = row.spec?.[action];
+ if (!def) {
+ return v;
+ }
+ for (const [key, value] of Object.entries(def)) {
+ v.push({ key, value });
+ }
+ return v;
+ }, [row.spec]);
+
+ return (
+
+
+ {hasSpec && (
+
+
+ Job Specification
+
+
+
+ )}
+ {hasErrors && (
+
+ {row.status?.errors?.map(
+ (error, index) =>
+ error.trim() && (
+
+
+
+ {error}
+
+
+ )
+ )}
+
+ )}
+ {hasSummary && (
+
+
+ Summary
+
+
+
+ )}
+
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+ No jobs...
+
+ );
+}
+
+function ErrorLoading(typ: string, error: any) {
+ return (
+
+ {JSON.stringify(error)}
+
+ );
+}
+
+function Loading() {
+ return (
+
+
+
+ );
+}
+
+export function RecentJobs({ repo }: Props) {
+ // TODO: Decide on whether we want to wait on historic jobs to show the current ones.
+ // Gut feeling is that current jobs are far more important to show than historic ones.
+ const [jobs, activeQuery, historicQuery] = useRepositoryAllJobs({
+ repositoryName: repo.metadata?.name,
+ watch: true,
+ sort: 'active-first',
+ });
+ const jobColumns = useMemo(() => getJobColumns(), []);
+
+ let description: JSX.Element;
+ if (activeQuery.isLoading || historicQuery.isLoading) {
+ description = Loading();
+ } else if (activeQuery.isError) {
+ description = ErrorLoading('active jobs', activeQuery.error);
+ // TODO: Figure out what to do if historic fails. Maybe a separate card?
+ } else if (!jobs?.length) {
+ description = ;
+ } else {
+ description = (
+ `${item.metadata?.name}`}
+ renderExpandedRow={(row) => }
+ pageSize={10}
+ />
+ );
+ }
+
+ return (
+
+ Jobs
+ {description}
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/CheckRepository.tsx b/public/app/features/provisioning/Repository/CheckRepository.tsx
new file mode 100644
index 00000000000..8745c55b797
--- /dev/null
+++ b/public/app/features/provisioning/Repository/CheckRepository.tsx
@@ -0,0 +1,49 @@
+import { useEffect } from 'react';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { Button, Spinner } from '@grafana/ui';
+import { Repository, useCreateRepositoryTestMutation } from 'app/api/clients/provisioning';
+
+interface Props {
+ repository: Repository;
+}
+
+export function CheckRepository({ repository }: Props) {
+ const [testRepo, testQuery] = useCreateRepositoryTestMutation();
+ const name = repository.metadata?.name;
+
+ useEffect(() => {
+ const appEvents = getAppEvents();
+ if (testQuery.isSuccess) {
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Test started'],
+ });
+ } else if (testQuery.isError) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['Error testing repository', testQuery.error],
+ });
+ }
+ }, [testQuery.error, testQuery.isError, testQuery.isSuccess]);
+
+ const onClick = () => {
+ if (!name) {
+ return;
+ }
+ testRepo({ name, body: {} });
+ };
+
+ if (testQuery.isLoading) {
+ return ;
+ }
+
+ return (
+ <>
+
+ Check
+
+ >
+ );
+}
diff --git a/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx b/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx
new file mode 100644
index 00000000000..e1894079e11
--- /dev/null
+++ b/public/app/features/provisioning/Repository/DeleteRepositoryButton.tsx
@@ -0,0 +1,58 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { ConfirmModal, IconButton } from '@grafana/ui';
+import { useDeleteRepositoryMutation } from 'app/api/clients/provisioning';
+
+const appEvents = getAppEvents();
+
+interface Props {
+ name: string;
+ redirectTo?: string;
+}
+
+export function DeleteRepositoryButton({ name, redirectTo }: Props) {
+ const [deleteRepository, request] = useDeleteRepositoryMutation();
+ const [showModal, setShowModal] = useState(false);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (request.isSuccess) {
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Repository settings queued for deletion'],
+ });
+ setShowModal(false);
+ if (redirectTo) {
+ navigate(redirectTo);
+ }
+ }
+ }, [request.isSuccess, redirectTo, navigate]);
+
+ const onConfirm = useCallback(() => {
+ deleteRepository({ name });
+ }, [deleteRepository, name]);
+
+ return (
+ <>
+ {
+ setShowModal(true);
+ }}
+ />
+ setShowModal(false)}
+ />
+ >
+ );
+}
diff --git a/public/app/features/provisioning/Repository/EditRepositoryPage.tsx b/public/app/features/provisioning/Repository/EditRepositoryPage.tsx
new file mode 100644
index 00000000000..9357bfaec5a
--- /dev/null
+++ b/public/app/features/provisioning/Repository/EditRepositoryPage.tsx
@@ -0,0 +1,32 @@
+import { useParams } from 'react-router-dom-v5-compat';
+
+import { EmptyState, Text, TextLink } from '@grafana/ui';
+import { useGetRepositoryQuery } from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+
+import { ConfigForm } from '../Config/ConfigForm';
+import { PROVISIONING_URL } from '../constants';
+
+export default function EditRepositoryPage() {
+ const { name = '' } = useParams();
+ const query = useGetRepositoryQuery({ name });
+ //@ts-expect-error TODO add error types
+ const notFound = query.isError && query.error?.status === 404;
+ return (
+
+
+ {notFound ? (
+
+ Make sure the repository config exists in the configuration file.
+ Back to repositories
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryActions.tsx b/public/app/features/provisioning/Repository/RepositoryActions.tsx
new file mode 100644
index 00000000000..2d0c4c5144c
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryActions.tsx
@@ -0,0 +1,34 @@
+import { Button, LinkButton, Stack } from '@grafana/ui';
+import { Repository } from 'app/api/clients/provisioning';
+
+import { StatusBadge } from '../Shared/StatusBadge';
+import { PROVISIONING_URL } from '../constants';
+import { getRepoHref } from '../utils/git';
+
+import { DeleteRepositoryButton } from './DeleteRepositoryButton';
+import { SyncRepository } from './SyncRepository';
+
+interface RepositoryActionsProps {
+ repository: Repository;
+}
+
+export function RepositoryActions({ repository }: RepositoryActionsProps) {
+ const name = repository.metadata?.name ?? '';
+ const repoHref = getRepoHref(repository.spec?.github);
+
+ return (
+
+
+ {repoHref && (
+ window.open(repoHref, '_blank')}>
+ Source Code
+
+ )}
+
+
+ Settings
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryCard.tsx b/public/app/features/provisioning/Repository/RepositoryCard.tsx
new file mode 100644
index 00000000000..58135da1961
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryCard.tsx
@@ -0,0 +1,125 @@
+import { ReactNode } from 'react';
+
+import { IconName, Stack, Text, TextLink, Icon, Card, LinkButton } from '@grafana/ui';
+import { Repository, ResourceCount } from 'app/api/clients/provisioning';
+
+import { StatusBadge } from '../Shared/StatusBadge';
+import { PROVISIONING_URL } from '../constants';
+
+import { DeleteRepositoryButton } from './DeleteRepositoryButton';
+import { SyncRepository } from './SyncRepository';
+
+interface Props {
+ repository: Repository;
+}
+
+export function RepositoryCard({ repository }: Props) {
+ const { metadata, spec, status } = repository;
+ const name = metadata?.name ?? '';
+
+ const getRepositoryMeta = (): ReactNode[] => {
+ const meta: ReactNode[] = [];
+
+ if (spec?.type === 'github') {
+ const { url = '', branch } = spec.github ?? {};
+ const branchUrl = branch ? `${url}/tree/${branch}` : url;
+
+ meta.push(
+
+ {branchUrl}
+
+ );
+
+ if (status?.webhook?.id) {
+ const webhookUrl = `${url}/settings/hooks/${status.webhook.id}`;
+ meta.push(
+
+
+ Webhook
+
+
+
+ );
+ }
+ } else if (spec?.type === 'local') {
+ meta.push(
+
+ {spec.local?.path ?? ''}
+
+ );
+ }
+
+ return meta;
+ };
+
+ const getRepositoryIcon = (): IconName => {
+ return spec?.type === 'github' ? 'github' : 'database';
+ };
+
+ return (
+
+
+
+
+
+
+ {spec?.title && {spec.title} }
+
+
+
+
+
+
+ {spec?.description && {spec.description} }
+ {status?.stats?.length && (
+
+ {status.stats.map((stat, index) => (
+
+ {stat.count} {stat.resource}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {getRepositoryMeta()}
+
+
+
+
+
+
+ View
+
+
+
+ Settings
+
+
+
+
+
+
+
+ );
+}
+
+// Helper function
+function getListURL(repo: Repository, stats: ResourceCount): string {
+ if (stats.resource === 'playlists') {
+ return '/playlists';
+ }
+ if (repo.spec?.sync.target === 'folder') {
+ return `/dashboards/f/${repo.metadata?.name}`;
+ }
+ return '/dashboards';
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryHealth.tsx b/public/app/features/provisioning/Repository/RepositoryHealth.tsx
new file mode 100644
index 00000000000..03dbd60e5c5
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryHealth.tsx
@@ -0,0 +1,31 @@
+import { Stack, Alert, Text } from '@grafana/ui';
+import { HealthStatus } from 'app/api/clients/provisioning';
+
+interface Props {
+ health: HealthStatus;
+}
+
+export function RepositoryHealth({ health }: Props) {
+ return (
+
+ {health.healthy ? (
+
+ No errors found
+
+ ) : (
+
+ {health.message && health.message.length > 0 && (
+ <>
+ Details:
+
+ {health.message.map((message) => (
+ {message}
+ ))}
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryOverview.tsx b/public/app/features/provisioning/Repository/RepositoryOverview.tsx
new file mode 100644
index 00000000000..69460f1a9d6
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryOverview.tsx
@@ -0,0 +1,233 @@
+import { css } from '@emotion/css';
+import { useMemo } from 'react';
+
+import {
+ CellProps,
+ Stack,
+ Box,
+ Text,
+ LinkButton,
+ Card,
+ TextLink,
+ InteractiveTable,
+ Grid,
+ useStyles2,
+} from '@grafana/ui';
+import { Repository, ResourceCount } from 'app/api/clients/provisioning';
+
+import { RecentJobs } from '../Job/RecentJobs';
+import { formatTimestamp } from '../utils/time';
+
+import { CheckRepository } from './CheckRepository';
+import { RepositoryHealth } from './RepositoryHealth';
+import { SyncRepository } from './SyncRepository';
+
+type StatCell = CellProps;
+
+export function RepositoryOverview({ repo }: { repo: Repository }) {
+ const styles = useStyles2(getStyles);
+ const status = repo.status;
+ const webhookURL = getWebhookURL(repo);
+
+ const resourceColumns = useMemo(
+ () => [
+ {
+ id: 'Resource',
+ header: 'Resource Type',
+ cell: ({ row: { original } }: StatCell<'resource'>) => {
+ return {original.resource} ;
+ },
+ size: 'auto',
+ },
+ {
+ id: 'count',
+ header: 'Count',
+ cell: ({ row: { original } }: StatCell<'count'>) => {
+ return {original.count} ;
+ },
+ size: 100,
+ },
+ ],
+ []
+ );
+ return (
+
+
+
+
+
+ Resources
+
+ {repo.status?.stats ? (
+ `${r.group}-${r.resource}`}
+ />
+ ) : null}
+
+
+
+ View Folder
+
+
+
+
+ {repo.status?.health && (
+
+
+ Health
+
+
+
+
+ Status:
+
+
+ {status?.health?.healthy ? 'Healthy' : 'Unhealthy'}
+
+
+
+ Checked:
+
+
+ {formatTimestamp(status?.health?.checked)}
+
+
+ {!!status?.health?.message?.length && (
+ <>
+
+ Messages:
+
+
+
+ {status.health.message.map((msg, idx) => (
+
+ {msg}
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+
+
+ Pull status
+
+
+
+ Status:
+
+
+ {status?.sync.state ?? 'N/A'}
+
+
+
+ Job ID:
+
+
+ {status?.sync.job ?? 'N/A'}
+
+
+
+ Last Ref:
+
+
+ {status?.sync.lastRef ? status.sync.lastRef.substring(0, 7) : 'N/A'}
+
+
+
+ Started:
+
+
+ {formatTimestamp(status?.sync.started)}
+
+
+
+ Finished:
+
+
+ {formatTimestamp(status?.sync.finished)}
+
+
+ {!!status?.sync?.message?.length && (
+ <>
+
+ Messages:
+
+
+
+ {status.sync.message.map((msg, idx) => (
+
+ {msg}
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+ {webhookURL && (
+
+ Webhook
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function getFolderURL(repo: Repository) {
+ if (repo.spec?.sync.target === 'folder') {
+ return `/dashboards/f/${repo.metadata?.name}`;
+ }
+ return '/dashboards';
+}
+
+const getStyles = () => {
+ return {
+ cardContainer: css({
+ height: '100%',
+ }),
+ card: css({
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ }),
+ actions: css({
+ marginTop: 'auto',
+ }),
+ labelColumn: css({
+ gridColumn: 'span 3',
+ }),
+ valueColumn: css({
+ gridColumn: 'span 9',
+ }),
+ };
+};
+
+function getWebhookURL(repo: Repository) {
+ const { status, spec } = repo;
+ if (spec?.type === 'github' && status?.webhook?.url && spec.github?.url) {
+ return `${spec.github.url}/settings/hooks/${status.webhook?.id}`;
+ }
+ return undefined;
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryResources.tsx b/public/app/features/provisioning/Repository/RepositoryResources.tsx
new file mode 100644
index 00000000000..aff409f9ec7
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryResources.tsx
@@ -0,0 +1,121 @@
+import { useMemo, useState } from 'react';
+
+import { CellProps, Column, FilterInput, InteractiveTable, Link, LinkButton, Spinner, Stack } from '@grafana/ui';
+import { Repository, ResourceListItem, useGetRepositoryResourcesQuery } from 'app/api/clients/provisioning';
+
+import { PROVISIONING_URL } from '../constants';
+
+interface RepoProps {
+ repo: Repository;
+}
+
+type ResourceCell = CellProps<
+ ResourceListItem,
+ ResourceListItem[T]
+>;
+
+export function RepositoryResources({ repo }: RepoProps) {
+ const name = repo.metadata?.name ?? '';
+ const query = useGetRepositoryResourcesQuery({ name });
+ const [searchQuery, setSearchQuery] = useState('');
+ const data = (query.data?.items ?? []).filter((Resource) =>
+ Resource.path.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ const columns: Array> = useMemo(
+ () => [
+ {
+ id: 'title',
+ header: 'Title',
+ sortType: 'string',
+ cell: ({ row: { original } }: ResourceCell<'title'>) => {
+ const { resource, name, title } = original;
+ if (resource === 'dashboards') {
+ return {title} ;
+ }
+ if (resource === 'folders') {
+ return {title} ;
+ }
+ return {title} ;
+ },
+ },
+ {
+ id: 'resource',
+ header: 'Type',
+ sortType: 'string',
+ cell: ({ row: { original } }: ResourceCell<'resource'>) => {
+ return {original.resource} ;
+ },
+ },
+ {
+ id: 'path',
+ header: 'Path',
+ sortType: 'string',
+ cell: ({ row: { original } }: ResourceCell<'path'>) => {
+ const { resource, name, path } = original;
+ if (resource === 'dashboards') {
+ return {path} ;
+ }
+ return {path} ;
+ },
+ },
+ {
+ id: 'hash',
+ header: 'Hash',
+ sortType: 'string',
+ cell: ({ row: { original } }: ResourceCell<'hash'>) => {
+ const { hash } = original;
+ return {hash.substring(0, 7)} ;
+ },
+ },
+ {
+ id: 'folder',
+ header: 'Folder',
+ sortType: 'string',
+ cell: ({ row: { original } }: ResourceCell<'title'>) => {
+ const { folder } = original;
+ if (folder?.length) {
+ return {folder};
+ }
+ return ;
+ },
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row: { original } }: ResourceCell) => {
+ const { resource, name, path } = original;
+ return (
+
+ {resource === 'dashboards' && View }
+ {resource === 'folders' && View }
+ History
+
+ );
+ },
+ },
+ ],
+ [repo.metadata?.name]
+ );
+
+ if (query.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ String(r.path)}
+ />
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx
new file mode 100644
index 00000000000..e12340af45a
--- /dev/null
+++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx
@@ -0,0 +1,99 @@
+import { useLocation } from 'react-router';
+import { useParams } from 'react-router-dom-v5-compat';
+
+import { SelectableValue, urlUtil } from '@grafana/data';
+import { Alert, EmptyState, Spinner, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui';
+import { useGetFrontendSettingsQuery, useListRepositoryQuery } from 'app/api/clients/provisioning';
+import { Page } from 'app/core/components/Page/Page';
+import { useQueryParams } from 'app/core/hooks/useQueryParams';
+import { isNotFoundError } from 'app/features/alerting/unified/api/util';
+
+import { FilesView } from '../File/FilesView';
+import { PROVISIONING_URL } from '../constants';
+
+import { RepositoryActions } from './RepositoryActions';
+import { RepositoryOverview } from './RepositoryOverview';
+import { RepositoryResources } from './RepositoryResources';
+
+enum TabSelection {
+ Overview = 'overview',
+ Resources = 'resources',
+ Files = 'files',
+}
+
+const tabInfo: SelectableValue = [
+ { value: TabSelection.Overview, label: 'Overview', title: 'Repository overview' },
+ { value: TabSelection.Resources, label: 'Resources', title: 'Resources saved in grafana database' },
+ { value: TabSelection.Files, label: 'Files', title: 'The raw file list from the repository' },
+];
+
+export default function RepositoryStatusPage() {
+ const { name = '' } = useParams();
+
+ const query = useListRepositoryQuery({
+ fieldSelector: `metadata.name=${name}`,
+ watch: true,
+ });
+ const data = query.data?.items?.[0];
+ const location = useLocation();
+ const [queryParams] = useQueryParams();
+ const settings = useGetFrontendSettingsQuery();
+ const tab = queryParams['tab'] ?? TabSelection.Overview;
+
+ const notFound = query.isError && isNotFoundError(query.error);
+
+ return (
+ }
+ >
+
+ {settings.data?.legacyStorage && (
+
+ Instance is not yet running unified storage -- requires migration wizard
+
+ )}
+ {notFound ? (
+
+ Make sure the repository config exists in the configuration file.
+ Back to repositories
+
+ ) : (
+ <>
+ {data ? (
+ <>
+
+ {tabInfo.map((t: SelectableValue) => (
+
+ ))}
+
+
+ {data?.metadata?.deletionTimestamp && (
+
+ Cleaning up repository resources
+
+ )}
+ {tab === TabSelection.Overview && }
+ {tab === TabSelection.Resources && }
+ {tab === TabSelection.Files && }
+
+ >
+ ) : (
+ not found
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Repository/SyncRepository.tsx b/public/app/features/provisioning/Repository/SyncRepository.tsx
new file mode 100644
index 00000000000..f6e1468e446
--- /dev/null
+++ b/public/app/features/provisioning/Repository/SyncRepository.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { Button, ConfirmModal } from '@grafana/ui';
+import { Repository, useCreateRepositorySyncMutation } from 'app/api/clients/provisioning';
+
+import { PROVISIONING_URL } from '../constants';
+
+interface Props {
+ repository: Repository;
+}
+
+export function SyncRepository({ repository }: Props) {
+ const [syncResource, syncQuery] = useCreateRepositorySyncMutation();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const navigate = useNavigate();
+ const name = repository.metadata?.name;
+
+ useEffect(() => {
+ const appEvents = getAppEvents();
+ if (syncQuery.isSuccess) {
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Pull started'],
+ });
+ } else if (syncQuery.isError) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['Error pulling resources', syncQuery.error],
+ });
+ }
+ }, [syncQuery.error, syncQuery.isError, syncQuery.isSuccess]);
+
+ const onClick = () => {
+ if (!name) {
+ return;
+ }
+ syncResource({ name, body: { incremental: false } }); // will queue a full resync job
+ setIsModalOpen(false);
+ };
+
+ const isHealthy = Boolean(repository.status?.health.healthy);
+
+ return (
+ <>
+
+ Pull
+
+ {!repository.spec?.sync.enabled && (
+ navigate(`${PROVISIONING_URL}/${name}/edit`)}
+ onDismiss={() => setIsModalOpen(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/public/app/features/provisioning/Shared/CodeBlock.tsx b/public/app/features/provisioning/Shared/CodeBlock.tsx
new file mode 100644
index 00000000000..4387354dbca
--- /dev/null
+++ b/public/app/features/provisioning/Shared/CodeBlock.tsx
@@ -0,0 +1,52 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { ClipboardButton, useStyles2, CodeEditor } from '@grafana/ui';
+
+interface Props {
+ code: string;
+ copyCode?: boolean;
+}
+
+export const CodeBlock = ({ code, copyCode = true }: Props) => {
+ const lineCount = code.split('\n').length;
+ const useMinHeight = lineCount * 24 <= 42; // 24px per line, 42px minimum
+ const styles = useStyles2(getStyles);
+
+ return (
+
+ {copyCode && (
+ code} />
+ )}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css({
+ position: 'relative',
+ margin: `${theme.spacing(2)} 0`,
+ border: `1px solid ${theme.colors.border.medium}`,
+ }),
+ copyButton: css({
+ position: 'absolute',
+ top: theme.spacing(1),
+ right: theme.spacing(1),
+ zIndex: 1,
+ }),
+});
diff --git a/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx
new file mode 100644
index 00000000000..e7a4ae6c3ce
--- /dev/null
+++ b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx
@@ -0,0 +1,37 @@
+import { LinkButton } from '@grafana/ui';
+import { Repository } from 'app/api/clients/provisioning';
+
+import { CONNECT_URL } from '../constants';
+import { checkSyncSettings } from '../utils/checkSyncSettings';
+
+interface Props {
+ items?: Repository[];
+}
+
+export function ConnectRepositoryButton({ items }: Props) {
+ const state = checkSyncSettings(items);
+
+ if (state.instanceConnected) {
+ return null;
+ }
+
+ if (state.maxReposReached) {
+ return (
+
+ Maximum repositories exist ({state.repoCount})
+
+ );
+ }
+
+ return (
+
+ Connect to repository
+
+ );
+}
diff --git a/public/app/features/provisioning/Shared/FolderRepositoryList.tsx b/public/app/features/provisioning/Shared/FolderRepositoryList.tsx
new file mode 100644
index 00000000000..b15a75d1851
--- /dev/null
+++ b/public/app/features/provisioning/Shared/FolderRepositoryList.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react';
+
+import { EmptySearchResult, FilterInput, Stack } from '@grafana/ui';
+import { Repository } from 'app/api/clients/provisioning';
+
+import { RepositoryCard } from '../Repository/RepositoryCard';
+
+import { ConnectRepositoryButton } from './ConnectRepositoryButton';
+
+interface Props {
+ items: Repository[];
+}
+
+export function FolderRepositoryList({ items }: Props) {
+ const [query, setQuery] = useState('');
+ const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
+
+ return (
+
+
+
+
+
+
+ {filteredItems.length ? (
+ filteredItems.map((item) => )
+ ) : (
+ No results matching your query
+ )}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Shared/ProgressBar.tsx b/public/app/features/provisioning/Shared/ProgressBar.tsx
new file mode 100644
index 00000000000..10529710f33
--- /dev/null
+++ b/public/app/features/provisioning/Shared/ProgressBar.tsx
@@ -0,0 +1,41 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+interface ProgressBarProps {
+ progress?: number;
+}
+const ProgressBar = ({ progress }: ProgressBarProps) => {
+ const styles = useStyles2(getStyles);
+
+ if (progress === undefined) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css({
+ height: '10px',
+ width: '400px',
+ backgroundColor: theme.colors.background.secondary,
+ borderRadius: theme.shape.radius.pill,
+ overflow: 'hidden',
+ margin: theme.spacing(2, 0),
+ }),
+ filler: css({
+ height: '100%',
+ background: theme.colors.gradients.brandHorizontal,
+ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
+ transition: 'width 0.5s ease-in-out',
+ },
+ }),
+});
+
+export default ProgressBar;
diff --git a/public/app/features/provisioning/Shared/StatusBadge.tsx b/public/app/features/provisioning/Shared/StatusBadge.tsx
new file mode 100644
index 00000000000..a69ceafef4e
--- /dev/null
+++ b/public/app/features/provisioning/Shared/StatusBadge.tsx
@@ -0,0 +1,70 @@
+import { locationService } from '@grafana/runtime';
+import { Badge, BadgeColor, IconName } from '@grafana/ui';
+import { Repository } from 'app/api/clients/provisioning';
+
+import { PROVISIONING_URL } from '../constants';
+
+interface StatusBadgeProps {
+ repo?: Repository;
+}
+
+export function StatusBadge({ repo }: StatusBadgeProps) {
+ if (!repo) {
+ return null;
+ }
+
+ let tooltip: string | undefined = undefined;
+ let color: BadgeColor = 'purple';
+ let text = 'Unknown';
+ let icon: IconName = 'exclamation-triangle';
+
+ if (repo.metadata?.deletionTimestamp) {
+ color = 'red';
+ text = 'Deleting';
+ icon = 'spinner';
+ } else if (!repo.spec?.sync?.enabled) {
+ color = 'red';
+ text = 'Automatic pulling disabled';
+ icon = 'info-circle';
+ } else if (!repo.status?.sync?.state?.length) {
+ color = 'orange';
+ text = 'Pending';
+ icon = 'spinner';
+ tooltip = 'Waiting for health check to run';
+ } else {
+ // Sync state
+ switch (repo.status?.sync?.state) {
+ case 'success':
+ icon = 'check';
+ text = 'Up-to-date';
+ color = 'green';
+ break;
+ case 'working':
+ case 'pending':
+ color = 'orange';
+ text = 'Pulling';
+ icon = 'spinner';
+ break;
+ case 'error':
+ color = 'red';
+ text = 'Error';
+ icon = 'exclamation-triangle';
+ break;
+ default:
+ break;
+ }
+ }
+
+ return (
+ {
+ locationService.push(`${PROVISIONING_URL}/${name}/?tab=overview`);
+ }}
+ />
+ );
+}
diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx
new file mode 100644
index 00000000000..84893e99186
--- /dev/null
+++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx
@@ -0,0 +1,69 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { TextLink, useStyles2 } from '@grafana/ui';
+
+export function TokenPermissionsInfo() {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+ Go to{' '}
+
+ GitHub Personal Access Tokens
+
+ . Make sure to include these permissions under Repository :
+
+
+
+
+
+ Permission
+ Access
+
+
+ Contents
+ Read and write
+
+
+ Metadata
+ Read-only
+
+
+ Pull requests
+ Read and write
+
+
+ Webhooks
+ Read and write
+
+
+
+
+ );
+}
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ container: css({
+ marginBottom: theme.spacing(1),
+ backgroundColor: theme.colors.background.secondary,
+ border: `1px solid ${theme.colors.border.weak}`,
+ position: 'relative',
+ borderRadius: theme.shape.radius.default,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ flex: '1 1 0',
+ padding: theme.spacing(theme.components.panel.padding),
+ }),
+ permissionTable: css({
+ tableLayout: 'auto',
+ width: '40%',
+ }),
+ headerSeparator: css({
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+ }),
+ };
+}
diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx
new file mode 100644
index 00000000000..1b45cd6c632
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx
@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import {
+ Alert,
+ Box,
+ Card,
+ Field,
+ FieldSet,
+ Icon,
+ Input,
+ LoadingPlaceholder,
+ Stack,
+ Switch,
+ Text,
+ Tooltip,
+} from '@grafana/ui';
+import { RepositoryViewList, useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/clients/provisioning';
+
+import { StepStatus } from '../hooks/useStepStatus';
+
+import { getState } from './actions';
+import { ModeOption, WizardFormData } from './types';
+
+interface Props {
+ onOptionSelect: (requiresMigration: boolean) => void;
+ onStepUpdate: (status: StepStatus, error?: string) => void;
+ settingsData?: RepositoryViewList;
+ repoName: string;
+}
+
+export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) {
+ const {
+ register,
+ control,
+ setValue,
+ watch,
+ formState: { errors },
+ } = useFormContext();
+
+ const selectedTarget = watch('repository.sync.target');
+ const repoType = watch('repository.type');
+
+ const resourceStats = useGetResourceStatsQuery();
+ const filesQuery = useGetRepositoryFilesQuery({ name: repoName });
+ const [selectedOption, setSelectedOption] = useState(null);
+
+ const state = useMemo(() => {
+ return getState(repoName, settingsData, filesQuery.data, resourceStats.data);
+ }, [repoName, settingsData, resourceStats.data, filesQuery.data]);
+
+ useEffect(() => {
+ if (state.actions.length && !selectedOption) {
+ const first = state.actions[0];
+ setSelectedOption(first);
+ onOptionSelect(first.operation === 'migrate');
+ setValue('repository.sync.target', first.target);
+ }
+ }, [state, selectedOption, setValue, onOptionSelect]);
+
+ const handleOptionSelect = useCallback(
+ (option: ModeOption) => {
+ // Select the new option and update form state
+ setSelectedOption(option);
+ setValue('repository.sync.target', option.target);
+
+ if (option.operation === 'migrate') {
+ setValue('migrate.history', true);
+ setValue('migrate.identifier', true);
+ }
+ onOptionSelect(option.operation === 'migrate');
+ },
+ [setValue, onOptionSelect]
+ );
+
+ if (resourceStats.isLoading || filesQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Grafana
+
+
+
+ {state.resourceCount > 0 ? state.resourceCountString : 'Empty'}
+
+
+
+
+
+ Repository
+
+ {state.fileCount > 0 ? `${state.fileCount} files` : 'Empty'}
+
+
+
+
+ (
+ <>
+ {state.actions.map((action, index) => (
+ {
+ handleOptionSelect(action);
+ }}
+ autoFocus={index === 0}
+ >
+ {action.label}
+ {action.description}
+
+ ))}
+ >
+ )}
+ />
+
+ {/* Add migration options */}
+ {selectedOption?.operation === 'migrate' && (
+ <>
+ {Boolean(state.resourceCount) && (
+
+ Dashboards will be unavailable while running this process
+
+ )}
+ {Boolean(state.fileCount) && Boolean(state.resourceCount) && (
+
+ The {state.resourceCount} resources in grafana will be added to the repository. Grafana will then
+ include both the current resources and anything from the repository when done.
+
+ )}
+
+
+
+
+ Include identifiers
+
+
+
+
+ {repoType === 'github' && settingsData?.legacyStorage && (
+
+
+ Include history
+
+
+
+
+ )}
+
+
+ >
+ )}
+
+ {/* Only show title field if folder sync */}
+ {selectedTarget === 'folder' && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/ConnectPage.tsx b/public/app/features/provisioning/Wizard/ConnectPage.tsx
new file mode 100644
index 00000000000..02a43a975be
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/ConnectPage.tsx
@@ -0,0 +1,19 @@
+import { Page } from 'app/core/components/Page/Page';
+
+import { ProvisioningWizard } from './ProvisioningWizard';
+
+export default function ConnectPage() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx
new file mode 100644
index 00000000000..e273d28cb54
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx
@@ -0,0 +1,123 @@
+import { useState } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { Combobox, ComboboxOption, Field, Input, SecretInput, Stack } from '@grafana/ui';
+
+import { getWorkflowOptions } from '../Config/ConfigForm';
+import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
+
+import { WizardFormData } from './types';
+
+const typeOptions: Array> = [
+ { label: 'GitHub', value: 'github' },
+ { label: 'Local', value: 'local' },
+];
+
+export function ConnectStep() {
+ const {
+ register,
+ control,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useFormContext();
+
+ const type = watch('repository.type');
+ const [tokenConfigured, setTokenConfigured] = useState(false);
+
+ const isGithub = type === 'github';
+
+ return (
+
+
+ {
+ const repoType = value?.value;
+ setValue('repository.type', repoType);
+ setValue(
+ 'repository.workflows',
+ getWorkflowOptions(repoType).map((v) => v.value)
+ );
+ }}
+ />
+
+
+ {isGithub && (
+ <>
+
+
+ {
+ return (
+ {
+ setValue('repository.token', '');
+ setTokenConfigured(false);
+ }}
+ />
+ );
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {type === 'local' && (
+
+
+
+ )}
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/FinishStep.tsx b/public/app/features/provisioning/Wizard/FinishStep.tsx
new file mode 100644
index 00000000000..a4f8be2b8c5
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/FinishStep.tsx
@@ -0,0 +1,128 @@
+import { css } from '@emotion/css';
+import { useEffect } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Field, Input, MultiCombobox, Stack, Switch, useStyles2 } from '@grafana/ui';
+
+import { getWorkflowOptions } from '../Config/ConfigForm';
+import { checkPublicAccess, checkImageRenderer } from '../GettingStarted/features';
+
+import { WizardFormData } from './types';
+
+export function FinishStep() {
+ const { register, watch, control, formState } = useFormContext();
+ const { errors } = formState;
+
+ const type = watch('repository.type');
+ const isGithub = type === 'github';
+ const isPublic = checkPublicAccess();
+ const hasImageRenderer = checkImageRenderer();
+ // Enable sync by default
+ const { setValue } = useFormContext();
+ const style = useStyles2(getStyles);
+
+ if (!isPublic || !hasImageRenderer) {
+ if (formState.defaultValues?.repository) {
+ formState.defaultValues.repository.generateDashboardPreviews = false;
+ }
+ }
+ if (!isPublic) {
+ if (formState.defaultValues?.repository) {
+ // TODO: Disable webhooks by default
+ }
+ }
+
+ // Set sync enabled by default
+ useEffect(() => {
+ setValue('repository.sync.enabled', true);
+ }, [setValue]);
+
+ return (
+
+ {isGithub && (
+
+
+
+ )}
+
+
+ (
+ {
+ onChange(val.map((v) => v.value));
+ }}
+ {...field}
+ />
+ )}
+ />
+
+
+ {isGithub && false /* TODO */ && (
+
+ {/* TODO: Make an option for the switch to control */}
+
+
+ )}
+
+ {isGithub && (
+
+ Enable dashboard previews in pull requests{' '}
+
+ (Requires image rendering.{' '}
+
+ Set up image rendering
+
+ )
+
+
+ }
+ description="Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be shared in your Git repository and visible to anyone with repository access."
+ disabled={!hasImageRenderer || !isPublic}
+ >
+
+
+ )}
+
+ );
+}
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ explanation: css({
+ color: theme.colors.text.disabled,
+ fontStyle: 'italic',
+ }),
+ explanationLink: css({
+ color: theme.colors.text.link,
+ fontStyle: 'italic',
+ }),
+ };
+}
diff --git a/public/app/features/provisioning/Wizard/JobStep.tsx b/public/app/features/provisioning/Wizard/JobStep.tsx
new file mode 100644
index 00000000000..41b65344c0b
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/JobStep.tsx
@@ -0,0 +1,79 @@
+import { ReactNode, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { useAsync } from 'react-use';
+
+import { Stack, Text } from '@grafana/ui';
+
+import { JobStatus } from '../Job/JobStatus';
+import { StepStatus, useStepStatus } from '../hooks/useStepStatus';
+
+import { WizardFormData } from './types';
+
+interface JobStepProps {
+ onStepUpdate: (status: StepStatus, error?: string) => void;
+ description: ReactNode;
+ startJob: (repositoryName: string) => Promise<{ metadata?: { name?: string } }>;
+ children?: ReactNode;
+}
+
+export type { JobStepProps };
+
+export function JobStep({ onStepUpdate, description, startJob, children }: JobStepProps) {
+ const { watch } = useFormContext();
+ const repositoryName = watch('repositoryName');
+ const stepStatus = useStepStatus({ onStepUpdate });
+ const [jobName, setJobName] = useState();
+
+ // Set initial running state outside the async operation
+ useAsync(async () => {
+ // Skip if we don't have a repository name or if we already started the job
+ if (!repositoryName || jobName) {
+ return;
+ }
+
+ // Only set running state when we're actually going to start the job
+ stepStatus.setRunning();
+
+ try {
+ const response = await startJob(repositoryName);
+ if (!response?.metadata?.name) {
+ throw new Error('Invalid response from operation');
+ }
+ setJobName(response.metadata.name);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to start operation';
+ stepStatus.setError(errorMessage);
+ throw error; // Re-throw to mark the async operation as failed
+ }
+ }, [repositoryName, jobName]); // Only depend on values that determine if we should start the job
+
+ return (
+
+ {description && {description} }
+ {children}
+
+ {jobName && (
+ {
+ if (success) {
+ stepStatus.setSuccess();
+ } else {
+ stepStatus.setError('Job failed');
+ }
+ }}
+ onRunningChange={(isRunning) => {
+ if (isRunning) {
+ stepStatus.setRunning();
+ }
+ }}
+ onErrorChange={(error) => {
+ if (error) {
+ stepStatus.setError(error);
+ }
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/MigrateStep.tsx b/public/app/features/provisioning/Wizard/MigrateStep.tsx
new file mode 100644
index 00000000000..ab01ba15ddc
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/MigrateStep.tsx
@@ -0,0 +1,37 @@
+import { useFormContext } from 'react-hook-form';
+
+import { useCreateRepositoryMigrateMutation } from 'app/api/clients/provisioning';
+
+import { StepStatus } from '../hooks/useStepStatus';
+
+import { JobStep } from './JobStep';
+import { WizardFormData } from './types';
+
+export interface MigrateStepProps {
+ onStepUpdate: (status: StepStatus, error?: string) => void;
+}
+
+export function MigrateStep({ onStepUpdate }: MigrateStepProps) {
+ const [migrateRepo] = useCreateRepositoryMigrateMutation();
+ const { watch } = useFormContext();
+ const identifier = watch('migrate.identifier');
+ const history = watch('migrate.history');
+
+ const startMigration = async (repositoryName: string) => {
+ const response = await migrateRepo({
+ name: repositoryName,
+ body: { identifier, history },
+ }).unwrap();
+
+ return response;
+ };
+
+ return (
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
new file mode 100644
index 00000000000..68dff166ec3
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useMemo, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
+
+import { getDefaultValues } from '../Config/ConfigForm';
+import { PROVISIONING_URL } from '../constants';
+
+import { Step } from './Stepper';
+import { WizardContent } from './WizardContent';
+import { WizardFormData, WizardStep } from './types';
+
+const steps: Array> = [
+ { id: 'connection', name: 'Connect', title: 'Connect to external storage', submitOnNext: true },
+ { id: 'bootstrap', name: 'Bootstrap', title: 'Bootstrap repository', submitOnNext: true },
+ { id: 'migrate', name: 'Resources', title: 'Migrate resources', submitOnNext: false },
+ { id: 'pull', name: 'Resources', title: 'Pull resources', submitOnNext: false },
+ { id: 'finish', name: 'Finish', title: 'Finish setup', submitOnNext: true },
+];
+
+export function ProvisioningWizard() {
+ const [activeStep, setActiveStep] = useState('connection');
+ const [completedSteps, setCompletedSteps] = useState([]);
+ const [stepSuccess, setStepSuccess] = useState(false);
+ const [requiresMigration, setRequiresMigration] = useState(false);
+ const settingsQuery = useGetFrontendSettingsQuery();
+ const navigate = useNavigate();
+ const values = getDefaultValues();
+
+ const methods = useForm({
+ defaultValues: {
+ repository: values,
+ migrate: {
+ history: true,
+ identifier: true, // Keep the same URLs
+ },
+ },
+ });
+
+ const handleStatusChange = useCallback(
+ (success: boolean) => {
+ setStepSuccess(success);
+ if (success) {
+ setCompletedSteps((prev) => [...prev, activeStep]);
+ }
+ },
+ [activeStep]
+ );
+
+ // Filter out migrate step if using legacy storage
+ const availableSteps = useMemo(() => {
+ return requiresMigration
+ ? steps.filter((step) => step.id !== 'pull')
+ : steps.filter((step) => step.id !== 'migrate');
+ }, [requiresMigration]);
+
+ // Calculate button text based on current step position
+ const getNextButtonText = (currentStep: WizardStep) => {
+ const stepIndex = availableSteps.findIndex((s) => s.id === currentStep);
+ if (currentStep === 'bootstrap') {
+ return 'Start';
+ }
+ return stepIndex === availableSteps.length - 1 ? 'Finish' : 'Next';
+ };
+
+ const handleNext = async () => {
+ const currentStepIndex = availableSteps.findIndex((s) => s.id === activeStep);
+ const isLastStep = currentStepIndex === availableSteps.length - 1;
+
+ if (activeStep === 'connection') {
+ // Validate repository form data before proceeding
+ const isValid = await methods.trigger('repository');
+ if (!isValid) {
+ return;
+ }
+
+ // Pick a name nice name based on type+settings
+ const current = methods.getValues();
+ switch (current.repository.type) {
+ case 'github':
+ const name = current.repository.url ?? 'github';
+ methods.setValue('repository.title', name.replace('https://github/', ''));
+ break;
+ case 'local':
+ methods.setValue('repository.title', current.repository.path ?? 'local');
+ break;
+ }
+ }
+
+ // If we're on the bootstrap step, determine the next step based on the migration flag
+ if (activeStep === 'bootstrap') {
+ const nextStep = requiresMigration ? 'migrate' : 'pull';
+ setActiveStep(nextStep);
+ return;
+ }
+
+ // Only navigate to provisioning URL if we're on the actual last step and it's completed
+ if (isLastStep && stepSuccess) {
+ settingsQuery.refetch();
+ navigate(PROVISIONING_URL);
+ return;
+ }
+
+ // For all other cases, proceed to next step
+ if (currentStepIndex < availableSteps.length - 1) {
+ setActiveStep(availableSteps[currentStepIndex + 1].id);
+ setStepSuccess(false);
+ // Update completed steps only if the current step was successful
+ if (stepSuccess) {
+ setCompletedSteps((prev) => [...prev, activeStep]);
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/PullStep.tsx b/public/app/features/provisioning/Wizard/PullStep.tsx
new file mode 100644
index 00000000000..09eeeafd0b3
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/PullStep.tsx
@@ -0,0 +1,29 @@
+import { useCreateRepositorySyncMutation } from 'app/api/clients/provisioning';
+
+import { StepStatus } from '../hooks/useStepStatus';
+
+import { JobStep } from './JobStep';
+
+interface PullStepProps {
+ onStepUpdate: (status: StepStatus, error?: string) => void;
+}
+
+export function PullStep({ onStepUpdate }: PullStepProps) {
+ const [syncRepo] = useCreateRepositorySyncMutation();
+
+ const startSync = async (repositoryName: string) => {
+ const response = await syncRepo({
+ name: repositoryName,
+ body: { incremental: false },
+ }).unwrap();
+ return response;
+ };
+
+ return (
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/RequestErrorAlert.tsx b/public/app/features/provisioning/Wizard/RequestErrorAlert.tsx
new file mode 100644
index 00000000000..e1b31d2cb7c
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/RequestErrorAlert.tsx
@@ -0,0 +1,39 @@
+import { Alert } from '@grafana/ui';
+import { getMessageFromError } from 'app/core/utils/errors';
+
+interface RequestErrorAlertProps {
+ request?: {
+ isError: boolean;
+ error?: unknown;
+ endpointName?: string;
+ } | null;
+ title?: string;
+}
+
+function getDefaultTitle(endpointName?: string): string {
+ switch (endpointName) {
+ case 'createRepositorySync':
+ return 'Failed to sync dashboards';
+ case 'createRepositoryMigrate':
+ return 'Failed to migrate dashboards';
+ case 'createOrUpdateRepository':
+ return 'Failed to save repository';
+ default:
+ return 'Operation failed';
+ }
+}
+
+export function RequestErrorAlert({ request, title }: RequestErrorAlertProps) {
+ if (!request || !request.isError) {
+ return null;
+ }
+
+ const errorTitle = title || getDefaultTitle(request.endpointName);
+ const errorMessage = getMessageFromError(request.error);
+
+ return (
+
+ {errorMessage}
+
+ );
+}
diff --git a/public/app/features/provisioning/Wizard/Stepper.tsx b/public/app/features/provisioning/Wizard/Stepper.tsx
new file mode 100644
index 00000000000..a69c427942e
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/Stepper.tsx
@@ -0,0 +1,114 @@
+import { css, cx } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2, Icon } from '@grafana/ui';
+
+import { ValidationResult } from './types';
+
+export interface Step {
+ id: T;
+ name: string;
+ title: string;
+ submitOnNext?: boolean;
+}
+
+export interface Props {
+ activeStep?: T;
+ reportId?: string;
+ visitedSteps?: T[];
+ steps: Array>;
+ validationResults: Record;
+}
+
+export function Stepper({
+ validationResults,
+ visitedSteps = [],
+ steps,
+ activeStep = steps[0]?.id,
+}: Props) {
+ const styles = useStyles2(getStyles);
+ const lastStep = steps[steps.length - 1];
+
+ return (
+
+ {steps.map((step) => {
+ const isLast = step.id === lastStep.id;
+ const isActive = step.id === activeStep;
+ const isVisited = visitedSteps.includes(step.id);
+ const hasMissingFields = !validationResults[step.id].valid;
+ const showIndicator = !isActive && isVisited;
+ const successField = showIndicator && !hasMissingFields;
+ const warnField = showIndicator && hasMissingFields;
+ const itemStyles = cx(styles.item, {
+ [styles.active]: isActive,
+ [styles.successItem]: successField,
+ [styles.warnItem]: warnField,
+ });
+
+ return (
+
+ {successField && }
+ {warnField && }
+ {step.name}
+ {!isLast && —
}
+
+ );
+ })}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ container: css({
+ counterReset: 'item',
+ listStyleType: 'none',
+ width: '100%',
+ position: 'relative',
+ display: 'flex',
+ justifyContent: 'center',
+ border: `1px solid ${theme.colors.border.weak}`,
+ margin: theme.spacing(4, 0),
+ }),
+ item: css({
+ color: theme.colors.text.secondary,
+ display: 'flex',
+ alignItems: 'center',
+ }),
+ successItem: css({
+ 'a::before': {
+ content: '""',
+ },
+ svg: {
+ color: theme.colors.success.text,
+ margin: theme.spacing(0, 0.5, 0, -1),
+ },
+ }),
+ warnItem: css({
+ 'a::before': {
+ content: '""',
+ },
+ svg: {
+ color: theme.colors.warning.text,
+ margin: theme.spacing(0, 1, 0.5, -0.5),
+ },
+ }),
+ link: css({
+ color: 'inherit',
+ '&::before': {
+ content: 'counter(item) " "',
+ counterIncrement: 'item',
+ },
+ }),
+ active: css({
+ fontWeight: 500,
+ color: theme.colors.text.maxContrast,
+ '&::before': {
+ fontWeight: 500,
+ },
+ }),
+ divider: css({
+ padding: theme.spacing(2),
+ }),
+ };
+};
diff --git a/public/app/features/provisioning/Wizard/WizardContent.tsx b/public/app/features/provisioning/Wizard/WizardContent.tsx
new file mode 100644
index 00000000000..70a839c1946
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/WizardContent.tsx
@@ -0,0 +1,245 @@
+import { css } from '@emotion/css';
+import { useCallback, useEffect, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents, GrafanaTheme2 } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
+import {
+ RepositoryViewList,
+ useDeleteRepositoryMutation,
+ useGetFrontendSettingsQuery,
+} from 'app/api/clients/provisioning';
+
+import { PROVISIONING_URL } from '../constants';
+import { useCreateOrUpdateRepository } from '../hooks';
+import { StepStatus } from '../hooks/useStepStatus';
+import { dataToSpec } from '../utils/data';
+
+import { BootstrapStep } from './BootstrapStep';
+import { ConnectStep } from './ConnectStep';
+import { FinishStep } from './FinishStep';
+import { MigrateStep } from './MigrateStep';
+import { PullStep } from './PullStep';
+import { RequestErrorAlert } from './RequestErrorAlert';
+import { Step, Stepper } from './Stepper';
+import { WizardFormData, WizardStep } from './types';
+
+const appEvents = getAppEvents();
+
+interface WizardContentProps {
+ activeStep: WizardStep;
+ completedSteps: WizardStep[];
+ availableSteps: Array>;
+ requiresMigration: boolean;
+ handleStatusChange: (success: boolean) => void;
+ handleNext: () => void;
+ getNextButtonText: (step: WizardStep) => string;
+ onOptionSelect: (requiresMigration: boolean) => void;
+ stepSuccess: boolean;
+ settingsData?: RepositoryViewList;
+}
+
+export function WizardContent({
+ activeStep,
+ completedSteps,
+ availableSteps,
+ requiresMigration,
+ handleStatusChange,
+ handleNext,
+ getNextButtonText,
+ onOptionSelect,
+ stepSuccess,
+ settingsData,
+}: WizardContentProps) {
+ const { watch, setValue, getValues, trigger } = useFormContext();
+ const navigate = useNavigate();
+
+ const repoName = watch('repositoryName');
+ const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName);
+ const [deleteRepository] = useDeleteRepositoryMutation();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isCancelling, setIsCancelling] = useState(false);
+ const [stepStatus, setStepStatus] = useState('idle');
+ const [stepError, setStepError] = useState();
+
+ const styles = useStyles2(getStyles);
+ const settingsQuery = useGetFrontendSettingsQuery();
+
+ const currentStep = availableSteps.find((s) => s.id === activeStep);
+ const currentStepIndex = availableSteps.findIndex((s) => s.id === activeStep);
+
+ const handleStepUpdate = useCallback((status: StepStatus, error?: string) => {
+ setStepStatus(status);
+ setStepError(error);
+ }, []);
+
+ // A different repository is marked with instance target -- nothing will succeed
+ if (settingsQuery.data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['Instance repository already exists'],
+ });
+ if (repoName) {
+ console.warn('Should we delete the pending repo?', repoName);
+ }
+ navigate(PROVISIONING_URL);
+ return null;
+ }
+
+ const handleRepositoryDeletion = async (name: string) => {
+ try {
+ await deleteRepository({ name });
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Repository deleted'],
+ });
+ // Wait before redirecting to ensure deletion is indexed
+ setTimeout(() => navigate(PROVISIONING_URL), 1500);
+ } catch (error) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['Failed to delete repository. Please try again.'],
+ });
+ setIsCancelling(false);
+ }
+ };
+
+ const handleCancel = async () => {
+ // For the first step, do not delete anything—just go back.
+ if (activeStep === 'connection' || !repoName) {
+ navigate(PROVISIONING_URL);
+ return;
+ }
+ setIsCancelling(true);
+ void handleRepositoryDeletion(repoName);
+ };
+
+ const handleNextWithSubmit = async () => {
+ if (currentStep?.submitOnNext) {
+ // Validate form data before proceeding
+ if (activeStep === 'connection' || activeStep === 'bootstrap') {
+ const isValid = await trigger(['repository', 'repository.title']);
+ if (!isValid) {
+ return;
+ }
+ }
+
+ setIsSubmitting(true);
+ try {
+ const formData = getValues();
+ const spec = dataToSpec(formData.repository);
+ const rsp = await submitData(spec);
+ if (rsp.error) {
+ // Error is displayed in
+ return;
+ }
+
+ // Fill in the k8s name from the initial POST response
+ const name = rsp.data?.metadata?.name;
+ if (name) {
+ setValue('repositoryName', name);
+ handleNext();
+ } else {
+ console.error('Saved repository without a name:', rsp);
+ }
+ } catch (error) {
+ console.error('Repository connection failed:', error);
+ handleStatusChange(false);
+ } finally {
+ setIsSubmitting(false);
+ }
+ } else {
+ // only proceed if the job was successful
+ if (stepSuccess || stepStatus === 'success') {
+ handleNext();
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (saveRequest.isSuccess) {
+ const newName = saveRequest.data?.metadata?.name;
+ if (newName) {
+ setValue('repositoryName', newName);
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Repository saved'],
+ });
+ handleStatusChange(true);
+ }
+ } else if (saveRequest.isError) {
+ handleStatusChange(false);
+ }
+ }, [saveRequest, setValue, handleStatusChange]);
+
+ const isNextButtonDisabled = () => {
+ return isSubmitting || isCancelling || stepStatus === 'running' || stepStatus === 'error';
+ };
+
+ return (
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ form: css({
+ maxWidth: '900px',
+ }),
+ content: css({
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+ paddingBottom: theme.spacing(4),
+ marginBottom: theme.spacing(4),
+ }),
+});
diff --git a/public/app/features/provisioning/Wizard/actions.ts b/public/app/features/provisioning/Wizard/actions.ts
new file mode 100644
index 00000000000..aa1758c67cf
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/actions.ts
@@ -0,0 +1,114 @@
+import {
+ GetRepositoryFilesApiResponse,
+ GetResourceStatsApiResponse,
+ RepositoryViewList,
+} from 'app/api/clients/provisioning';
+
+import { ModeOption, SystemState } from './types';
+
+const migrateInstance: ModeOption = {
+ target: 'instance',
+ operation: 'migrate',
+ label: 'Migrate instance to repository',
+ description: 'Save all Grafana resources in the repository',
+};
+
+const pullInstance: ModeOption = {
+ target: 'instance',
+ operation: 'pull',
+ label: 'Pull from repository to instance',
+ description: 'Pull resources from the repository into this Grafana instance',
+};
+
+const pullFolder: ModeOption = {
+ target: 'folder',
+ operation: 'pull',
+ label: 'Pull from repository to folder',
+ description: 'Pull repository resources into a repository-managed Grafana folder',
+};
+
+function getDisabledReason(action: ModeOption, resourceCount: number, folderConnected?: boolean) {
+ // Disable pull instance if there are existing dashboards or folders
+ if (action.target === 'instance' && action.operation === 'pull' && resourceCount > 0) {
+ return 'Cannot pull to instance when you have existing resources. Please migrate your existing resources first.';
+ }
+
+ if (!folderConnected) {
+ return undefined;
+ }
+
+ if (action.operation === 'migrate') {
+ return 'Cannot migrate when a folder is already mounted.';
+ }
+
+ if (action.target === 'instance') {
+ return 'Instance-wide connection is disabled because folders are connected to repositories.';
+ }
+
+ return undefined;
+}
+
+export function getState(
+ repoName: string,
+ settings?: RepositoryViewList,
+ files?: GetRepositoryFilesApiResponse,
+ stats?: GetResourceStatsApiResponse
+): SystemState {
+ const folderConnected = settings?.items?.some((item) => item.target === 'folder' && item.name !== repoName);
+
+ const fileCount =
+ files?.items?.reduce((count, file) => {
+ const path = file.path ?? '';
+ return path.endsWith('.json') || path.endsWith('.yaml') ? count + 1 : count;
+ }, 0) ?? 0;
+
+ let counts: string[] = [];
+ let resourceCount = 0;
+ stats?.instance?.forEach((stat) => {
+ switch (stat.group) {
+ case 'folders': // fallthrough
+ case 'folder.grafana.app':
+ resourceCount += stat.count;
+ counts.push(`${stat.count} ${stat.count > 1 ? 'folders' : 'folder'}`);
+ break;
+ case 'dashboard.grafana.app':
+ resourceCount += stat.count;
+ counts.push(`${stat.count} ${stat.count > 1 ? 'dashboards' : 'dashboard'}`);
+ break;
+ }
+ });
+
+ const state: SystemState = {
+ resourceCount,
+ resourceCountString: counts.join(',\n'),
+ fileCount,
+ actions: [],
+ disabled: [],
+ folderConnected,
+ };
+
+ // Legacy storage can only migrate
+ if (settings?.legacyStorage) {
+ const disabledReason = 'Instance must be migrated first';
+ state.actions = [migrateInstance];
+ state.disabled = [
+ { ...pullInstance, disabledReason },
+ { ...pullFolder, disabledReason },
+ ];
+ return state;
+ }
+
+ const actionsToEvaluate = resourceCount
+ ? [pullFolder, pullInstance, migrateInstance] // recommend pull when resources already exist
+ : [migrateInstance, pullInstance, pullFolder];
+ actionsToEvaluate.forEach((action) => {
+ const reason = getDisabledReason(action, resourceCount, folderConnected);
+ if (reason) {
+ state.disabled.push({ ...action, disabledReason: reason });
+ } else {
+ state.actions.push(action);
+ }
+ });
+
+ return state;
+}
diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts
new file mode 100644
index 00000000000..3a4a8abe342
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/types.ts
@@ -0,0 +1,42 @@
+import { SyncOptions } from 'app/api/clients/provisioning';
+
+import { RepositoryFormData } from '../types';
+
+export type WizardStep = 'connection' | 'bootstrap' | 'migrate' | 'pull' | 'finish';
+
+export interface MigrateFormData {
+ history: boolean;
+ identifier: boolean;
+}
+
+export interface WizardFormData {
+ repository: RepositoryFormData;
+ migrate?: MigrateFormData;
+ repositoryName?: string;
+}
+
+export type ValidationResult = {
+ valid: boolean;
+ errors?: string[];
+};
+
+export type Target = SyncOptions['target'];
+export type Operation = 'pull' | 'migrate';
+
+export interface ModeOption {
+ target: Target;
+ operation: Operation;
+ label: string;
+ description: string;
+ disabledReason?: string;
+}
+
+export interface SystemState {
+ resourceCount: number;
+ resourceCountString: string;
+
+ fileCount: number;
+ actions: ModeOption[];
+ disabled: ModeOption[];
+ folderConnected?: boolean;
+}
diff --git a/public/app/features/provisioning/hooks/index.ts b/public/app/features/provisioning/hooks/index.ts
new file mode 100644
index 00000000000..8bbb042c7e7
--- /dev/null
+++ b/public/app/features/provisioning/hooks/index.ts
@@ -0,0 +1,7 @@
+export { useCreateOrUpdateRepository } from './useCreateOrUpdateRepository';
+export { useCreateOrUpdateRepositoryFile } from './useCreateOrUpdateRepositoryFile';
+export { useGetResourceRepository } from './useGetResourceRepository';
+export { useIsProvisionedNG } from './useIsProvisionedNG';
+export { usePullRequestParam } from './usePullRequestParam';
+export { useRepositoryJobs } from './useRepositoryJobs';
+export { useRepositoryList } from './useRepositoryList';
diff --git a/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
index 16ffb82d5ca..f91c4f0cdca 100644
--- a/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
+++ b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
@@ -13,7 +13,18 @@ export function useCreateOrUpdateRepository(name?: string) {
const updateOrCreate = useCallback(
(data: RepositorySpec) => {
if (name) {
- return update({ name, repository: { metadata: { name }, spec: data } });
+ return update({
+ name,
+ repository: {
+ metadata: {
+ name,
+ // TODO? -- replace with patch spec, so the rest of the metadata is not replaced?
+ // Can that support optimistic locking? (eg, make sure the RV is the same?)
+ finalizers: ['cleanup', 'remove-orphan-resources'],
+ },
+ spec: data,
+ },
+ });
}
return create({ repository: { metadata: generateRepositoryMetadata(data), spec: data } });
},
diff --git a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts
index 5526ee759b5..f218fe855e9 100644
--- a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts
+++ b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts
@@ -2,10 +2,10 @@ import { skipToken } from '@reduxjs/toolkit/query';
import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
-import { checkSyncSettings } from '../utils/checkSyncSettings';
-
export function useIsProvisionedInstance(settings?: RepositoryViewList) {
const settingsQuery = useGetFrontendSettingsQuery(settings ? skipToken : undefined);
- const [instanceConnected] = checkSyncSettings(settings || settingsQuery.data);
- return instanceConnected;
+ if (!settings) {
+ settings = settingsQuery.data;
+ }
+ return settings?.items?.some((item) => item.target === 'instance');
}
diff --git a/public/app/features/provisioning/hooks/useIsProvisionedNG.ts b/public/app/features/provisioning/hooks/useIsProvisionedNG.ts
index 786290cff43..7be22150d77 100644
--- a/public/app/features/provisioning/hooks/useIsProvisionedNG.ts
+++ b/public/app/features/provisioning/hooks/useIsProvisionedNG.ts
@@ -1,13 +1,12 @@
import { config } from '@grafana/runtime';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
-import { useUrlParams } from 'app/core/navigation/hooks';
import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene';
import { useGetResourceRepository } from './useGetResourceRepository';
export function useIsProvisionedNG(dashboard: DashboardScene): boolean {
- const [params] = useUrlParams();
+ const params = new URLSearchParams(window.location.search);
const folderUid = params.get('folderUid') || undefined;
const folderRepository = useGetResourceRepository({ folderUid });
diff --git a/public/app/features/provisioning/hooks/usePullRequestParam.ts b/public/app/features/provisioning/hooks/usePullRequestParam.ts
index 2aefe258ddc..911a8ce988a 100644
--- a/public/app/features/provisioning/hooks/usePullRequestParam.ts
+++ b/public/app/features/provisioning/hooks/usePullRequestParam.ts
@@ -1,3 +1,4 @@
+import { textUtil } from '@grafana/data';
import { useUrlParams } from 'app/core/navigation/hooks';
export const usePullRequestParam = () => {
@@ -8,5 +9,5 @@ export const usePullRequestParam = () => {
return undefined;
}
- return decodeURIComponent(prParam);
+ return textUtil.sanitizeUrl(decodeURIComponent(prParam));
};
diff --git a/public/app/features/provisioning/hooks/useRepositoryAllJobs.ts b/public/app/features/provisioning/hooks/useRepositoryAllJobs.ts
new file mode 100644
index 00000000000..3801761b379
--- /dev/null
+++ b/public/app/features/provisioning/hooks/useRepositoryAllJobs.ts
@@ -0,0 +1,73 @@
+import { HistoricJob, Job, useListHistoricJobQuery, useListJobQuery } from 'app/api/clients/provisioning';
+
+interface RepositoryHistoricalJobsArgs {
+ /** Limits the returned jobs to those which have this job name (max 1 active, unlimited historic). */
+ jobName?: string;
+ /** Limits the returned jobs to those which apply to this repository. */
+ repositoryName?: string;
+ /** Whether to continue receiving more updates of the current jobs. */
+ watch?: boolean;
+ /**
+ * How to sort the resulting jobs.
+ *
+ * - `created-first`: All jobs are treated equally. The newest jobs are shown first.
+ * - `active-first`: Active jobs are shown first, then historic jobs. Within each group, the newest jobs are shown first.
+ */
+ sort?: 'created-first' | 'active-first';
+}
+
+function labelSelectorActive(repositoryName?: string): string | undefined {
+ return repositoryName ? `repository=${repositoryName}` : undefined;
+}
+
+function labelSelectorHistoric(repositoryName?: string, jobName?: string): string | undefined {
+ const repoName = repositoryName ? `provisioning.grafana.app/repository=${repositoryName}` : '';
+ const name = jobName ? `provisioning.grafana.app/original-name=${jobName}` : '';
+
+ const selector = [repoName, name].filter(Boolean).join(', ');
+ return !!selector ? selector : undefined;
+}
+
+function fieldSelectorActive(jobName?: string): string | undefined {
+ return jobName ? `metadata.name=${jobName}` : undefined;
+}
+
+export function useRepositoryAllJobs({
+ jobName,
+ repositoryName,
+ watch = true,
+ sort = 'created-first',
+}: RepositoryHistoricalJobsArgs = {}): [
+ Array | undefined,
+ ReturnType,
+ ReturnType,
+] {
+ const activeQuery = useListJobQuery({
+ labelSelector: labelSelectorActive(repositoryName),
+ fieldSelector: fieldSelectorActive(jobName),
+ watch,
+ });
+ const historicQuery = useListHistoricJobQuery({ labelSelector: labelSelectorHistoric(), watch });
+
+ const concatedItems = [...(activeQuery.data?.items ?? []), ...(historicQuery.data?.items ?? [])];
+ const collator = new Intl.Collator(undefined, { numeric: true });
+ const sortedItems = concatedItems.slice().sort((a, b) => {
+ if (sort === 'active-first') {
+ const aActive = a.kind === 'Job';
+ const bActive = b.kind === 'Job';
+
+ if (aActive && !bActive) {
+ return -1;
+ } else if (!aActive && bActive) {
+ return 1;
+ }
+ // otherwise, both are active or both are historic. Sort by creation timestamp.
+ }
+ const aTime = a.metadata?.creationTimestamp ?? '';
+ const bTime = b.metadata?.creationTimestamp ?? '';
+
+ return collator.compare(bTime, aTime); // Reverse order for newest first
+ });
+
+ return [sortedItems, activeQuery, historicQuery];
+}
diff --git a/public/app/features/provisioning/hooks/useRepositoryHistoricalJobs.ts b/public/app/features/provisioning/hooks/useRepositoryHistoricalJobs.ts
new file mode 100644
index 00000000000..259b6576d6c
--- /dev/null
+++ b/public/app/features/provisioning/hooks/useRepositoryHistoricalJobs.ts
@@ -0,0 +1,36 @@
+import { HistoricJob, useListHistoricJobQuery } from 'app/api/clients/provisioning';
+
+interface RepositoryHistoricalJobsArgs {
+ /** Limits the returned jobs to those which had this name before archival. */
+ originalJobName?: string;
+ /** Limits the returned jobs to those which apply to this repository. */
+ repositoryName?: string;
+ /** Whether to continue receiving more updates of the current historic jobs (i.e. if more come in; existing ones are immutable but may be deleted). */
+ watch?: boolean;
+}
+
+function labelSelectors({
+ originalJobName,
+ repositoryName,
+}: Pick): string | undefined {
+ const repoName = repositoryName ? `provisioning.grafana.app/repository=${repositoryName}` : '';
+ const originalName = originalJobName ? `provisioning.grafana.app/original-name=${originalJobName}` : '';
+
+ const selector = [repoName, originalName].filter(Boolean).join(', ');
+ return !!selector ? selector : undefined;
+}
+
+export function useRepositoryHistoricalJobs(
+ args: RepositoryHistoricalJobsArgs = {}
+): [HistoricJob[] | undefined, ReturnType] {
+ const query = useListHistoricJobQuery({ labelSelector: labelSelectors(args), watch: args.watch });
+
+ const collator = new Intl.Collator(undefined, { numeric: true });
+ const sortedItems = query.data?.items?.slice().sort((a, b) => {
+ const aTime = a.metadata?.creationTimestamp ?? '';
+ const bTime = b.metadata?.creationTimestamp ?? '';
+ return collator.compare(bTime, aTime); // Reverse order for newest first
+ });
+
+ return [sortedItems, query];
+}
diff --git a/public/app/features/provisioning/hooks/useStepStatus.ts b/public/app/features/provisioning/hooks/useStepStatus.ts
new file mode 100644
index 00000000000..eb8e37451f2
--- /dev/null
+++ b/public/app/features/provisioning/hooks/useStepStatus.ts
@@ -0,0 +1,25 @@
+import { useCallback } from 'react';
+
+export type StepStatus = 'idle' | 'running' | 'error' | 'success';
+
+export interface StepStatusProps {
+ onStepUpdate: (status: StepStatus, error?: string) => void;
+}
+
+export interface StepStatusActions {
+ setRunning: () => void;
+ setError: (error: string) => void;
+ setSuccess: () => void;
+}
+
+export function useStepStatus({ onStepUpdate }: StepStatusProps): StepStatusActions {
+ const setRunning = useCallback(() => onStepUpdate('running'), [onStepUpdate]);
+ const setError = useCallback((error: string) => onStepUpdate('error', error), [onStepUpdate]);
+ const setSuccess = useCallback(() => onStepUpdate('success'), [onStepUpdate]);
+
+ return {
+ setRunning,
+ setError,
+ setSuccess,
+ };
+}
diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts
index fbe4cbfda1d..e614c9969f9 100644
--- a/public/app/features/provisioning/types.ts
+++ b/public/app/features/provisioning/types.ts
@@ -12,3 +12,29 @@ export interface ProvisioningPreview {
}
export type WorkflowOption = 'branch' | 'write';
+
+export type HistoryItem = {
+ ref: string;
+ message: string;
+ createdAt?: number;
+ authors: AuthorInfo[];
+};
+
+export type AuthorInfo = {
+ name: string;
+ username: string;
+ avatarURL?: string;
+};
+
+export type FileDetails = {
+ path: string;
+ size: string;
+ hash: string;
+};
+
+export type HistoryListResponse = {
+ apiVersion?: string;
+ kind?: string;
+ metadata?: any;
+ items?: HistoryItem[];
+};
diff --git a/public/app/features/provisioning/utils/checkSyncSettings.ts b/public/app/features/provisioning/utils/checkSyncSettings.ts
index f7b4fe7b5f1..03ddcd48747 100644
--- a/public/app/features/provisioning/utils/checkSyncSettings.ts
+++ b/public/app/features/provisioning/utils/checkSyncSettings.ts
@@ -1,10 +1,25 @@
-import { RepositoryViewList } from 'app/api/clients/provisioning';
+import { Repository } from 'app/api/clients/provisioning';
-export function checkSyncSettings(settings?: RepositoryViewList): [boolean, boolean] {
- if (!settings?.items?.length) {
- return [false, false];
+type syncState = {
+ instanceConnected: boolean;
+ folderConnected: boolean;
+ repoCount: number;
+ maxReposReached: boolean;
+};
+
+export function checkSyncSettings(repos?: Repository[]): syncState {
+ if (!repos?.length) {
+ return {
+ instanceConnected: false,
+ folderConnected: false,
+ repoCount: 0,
+ maxReposReached: false,
+ };
}
- const instanceConnected = settings.items.some((item) => item.target === 'instance');
- const folderConnected = settings.items.some((item) => item.target === 'folder');
- return [instanceConnected, folderConnected];
+ return {
+ instanceConnected: repos.some((item) => item.spec?.sync.target === 'instance'),
+ folderConnected: repos.some((item) => item.spec?.sync.target === 'folder'),
+ maxReposReached: Boolean((repos ?? []).length >= 10),
+ repoCount: repos.length,
+ };
}
diff --git a/public/app/features/provisioning/utils/data.ts b/public/app/features/provisioning/utils/data.ts
index 35aed2955a8..1abd1f879e9 100644
--- a/public/app/features/provisioning/utils/data.ts
+++ b/public/app/features/provisioning/utils/data.ts
@@ -16,12 +16,14 @@ export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
url: data.url || '',
branch: data.branch,
token: data.token,
+ path: data.path,
};
break;
case 'local':
spec.local = {
path: data.path,
};
+ spec.workflows = spec.workflows.filter((v) => v !== 'branch'); // branch only supported by github
break;
}
@@ -35,5 +37,6 @@ export const specToData = (spec: RepositorySpec): RepositoryFormData => {
...spec.local,
branch: spec.github?.branch || '',
url: spec.github?.url || '',
+ generateDashboardPreviews: spec.github?.generateDashboardPreviews || false,
};
};
diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts
new file mode 100644
index 00000000000..1438fd42b33
--- /dev/null
+++ b/public/app/features/provisioning/utils/routes.ts
@@ -0,0 +1,83 @@
+import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
+import { RouteDescriptor } from 'app/core/navigation/types';
+import { DashboardRoutes } from 'app/types';
+
+import { checkRequiredFeatures } from '../GettingStarted/features';
+import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
+
+export function getProvisioningRoutes(): RouteDescriptor[] {
+ if (!checkRequiredFeatures()) {
+ return [
+ {
+ path: PROVISIONING_URL,
+ component: SafeDynamicImport(
+ () =>
+ import(
+ /* webpackChunkName: "GettingStartedPage"*/ 'app/features/provisioning/GettingStarted/GettingStartedPage'
+ )
+ ),
+ },
+ ];
+ }
+
+ return [
+ {
+ path: PROVISIONING_URL,
+ component: SafeDynamicImport(
+ () => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/HomePage')
+ ),
+ },
+ {
+ path: GETTING_STARTED_URL,
+ component: SafeDynamicImport(
+ () =>
+ import(
+ /* webpackChunkName: "GettingStartedPage"*/ 'app/features/provisioning/GettingStarted/GettingStartedPage'
+ )
+ ),
+ },
+ {
+ path: CONNECT_URL,
+ component: SafeDynamicImport(
+ () => import(/* webpackChunkName: "ProvisioningWizardPage"*/ 'app/features/provisioning/Wizard/ConnectPage')
+ ),
+ },
+ {
+ path: PROVISIONING_URL + '/:name',
+ component: SafeDynamicImport(
+ () =>
+ import(
+ /* webpackChunkName: "RepositoryStatusPage"*/ 'app/features/provisioning/Repository/RepositoryStatusPage'
+ )
+ ),
+ },
+ {
+ path: PROVISIONING_URL + '/:name/edit',
+ component: SafeDynamicImport(
+ () =>
+ import(/* webpackChunkName: "EditRepositoryPage"*/ 'app/features/provisioning/Repository/EditRepositoryPage')
+ ),
+ },
+ {
+ path: PROVISIONING_URL + '/:name/file/*',
+ component: SafeDynamicImport(
+ () => import(/* webpackChunkName: "FileStatusPage"*/ 'app/features/provisioning/File/FileStatusPage')
+ ),
+ },
+ {
+ path: PROVISIONING_URL + '/:name/history/*',
+ component: SafeDynamicImport(
+ () => import(/* webpackChunkName: "FileHistoryPage"*/ 'app/features/provisioning/File/FileHistoryPage')
+ ),
+ },
+ {
+ path: PROVISIONING_URL + '/:slug/dashboard/preview/*',
+ pageClass: 'page-dashboard',
+ routeName: DashboardRoutes.Provisioning,
+ component: SafeDynamicImport(
+ () =>
+ import(/* webpackChunkName: "DashboardScenePage" */ 'app/features/dashboard-scene/pages/DashboardScenePage')
+ ),
+ },
+ ];
+}
diff --git a/public/app/features/provisioning/utils/types.ts b/public/app/features/provisioning/utils/types.ts
deleted file mode 100644
index b2dec4abede..00000000000
--- a/public/app/features/provisioning/utils/types.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// TODO: This file should be removed when the swagger docs match the real payloads
-
-export type HistoryItem = {
- ref: string;
- message: string;
- createdAt?: number;
- authors: AuthorInfo[];
-};
-
-export type AuthorInfo = {
- name: string;
- username: string;
- avatarURL?: string;
-};
diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts
index 443a7cad8c6..358d1b97e4d 100644
--- a/public/app/features/scopes/ScopesApiClient.ts
+++ b/public/app/features/scopes/ScopesApiClient.ts
@@ -62,6 +62,10 @@ export class ScopesApiClient {
});
}
+ /**
+ * @param parent
+ * @param query Filters by title substring
+ */
async fetchNode(parent: string, query: string): Promise {
try {
const nodes =
diff --git a/public/app/features/scopes/ScopesContextProvider.tsx b/public/app/features/scopes/ScopesContextProvider.tsx
index 8e47b0e4b3a..9519b10be3b 100644
--- a/public/app/features/scopes/ScopesContextProvider.tsx
+++ b/public/app/features/scopes/ScopesContextProvider.tsx
@@ -1,6 +1,6 @@
-import { createContext, ReactNode, useMemo, useContext } from 'react';
+import { createContext, ReactNode, useMemo, useContext, useEffect } from 'react';
-import { config, ScopesContext } from '@grafana/runtime';
+import { config, locationService, ScopesContext } from '@grafana/runtime';
import { ScopesApiClient } from './ScopesApiClient';
import { ScopesService } from './ScopesService';
@@ -36,7 +36,7 @@ export function defaultScopesServices() {
const dashboardService = new ScopesDashboardsService(client);
const selectorService = new ScopesSelectorService(client, dashboardService);
return {
- scopesService: new ScopesService(selectorService, dashboardService),
+ scopesService: new ScopesService(selectorService, dashboardService, locationService),
scopesSelectorService: selectorService,
scopesDashboardsService: dashboardService,
client,
@@ -48,6 +48,12 @@ export const ScopesContextProvider = ({ children, services }: ScopesContextProvi
return services ?? defaultScopesServices();
}, [services]);
+ useEffect(() => {
+ return () => {
+ memoizedServices.scopesService.cleanUp();
+ };
+ }, [memoizedServices]);
+
return (
diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts
index 2c836470194..21969652552 100644
--- a/public/app/features/scopes/ScopesService.ts
+++ b/public/app/features/scopes/ScopesService.ts
@@ -1,8 +1,8 @@
import { isEqual } from 'lodash';
-import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
+import { BehaviorSubject, Observable, combineLatest, Subscription } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
-import { ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
+import { LocationService, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './selector/ScopesSelectorService';
@@ -24,9 +24,12 @@ export class ScopesService implements ScopesContextValue {
// This will contain the combined state that will be public.
private readonly _stateObservable: BehaviorSubject;
+ private subscriptions: Subscription[] = [];
+
constructor(
private selectorService: ScopesSelectorService,
- private dashboardsService: ScopesDashboardsService
+ private dashboardsService: ScopesDashboardsService,
+ private locationService: LocationService
) {
this._state = new BehaviorSubject({
enabled: false,
@@ -41,24 +44,64 @@ export class ScopesService implements ScopesContextValue {
});
// We combine the latest emissions from this state + selectorService + dashboardsService.
- combineLatest([
- this._state.asObservable(),
- this.getSelectorServiceStateObservable(),
- this.getDashboardsServiceStateObservable(),
- ])
- .pipe(
- // Map the 3 states into single ScopesContextValueState object
- map(
- ([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({
- ...thisState,
- value: selectorState.selectedScopes,
- loading: selectorState.loading,
- drawerOpened: dashboardsState.drawerOpened,
- })
+ this.subscriptions.push(
+ combineLatest([
+ this._state.asObservable(),
+ this.getSelectorServiceStateObservable(),
+ this.getDashboardsServiceStateObservable(),
+ ])
+ .pipe(
+ // Map the 3 states into single ScopesContextValueState object
+ map(
+ ([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({
+ ...thisState,
+ value: selectorState.selectedScopes,
+ loading: selectorState.loading,
+ drawerOpened: dashboardsState.drawerOpened,
+ })
+ )
)
- )
- // We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value.
- .subscribe(this._stateObservable);
+ // We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value.
+ .subscribe(this._stateObservable)
+ );
+
+ // Init from the URL when we first load
+ const queryParams = new URLSearchParams(locationService.getLocation().search);
+ this.changeScopes(queryParams.getAll('scopes'));
+
+ // Update scopes state based on URL.
+ this.subscriptions.push(
+ locationService.getLocationObservable().subscribe((location) => {
+ if (!this.state.enabled) {
+ // We don't need to react on pages that don't interact with scopes.
+ return;
+ }
+ const queryParams = new URLSearchParams(location.search);
+ const scopes = queryParams.getAll('scopes');
+ if (scopes.length) {
+ // We only update scopes but never delete them. This is to keep the scopes in memory if user navigates to
+ // page that does not use scopes (like from dashboard to dashboard list back to dashboard). If user
+ // changes the URL directly, it would trigger a reload so scopes would still be reset.
+ this.changeScopes(scopes);
+ }
+ })
+ );
+
+ // Update the URL based on change in the scopes state
+ this.subscriptions.push(
+ selectorService.subscribeToState((state, prev) => {
+ const oldScopeNames = prev.selectedScopes.map((scope) => scope.scope.metadata.name);
+ const newScopeNames = state.selectedScopes.map((scope) => scope.scope.metadata.name);
+ if (!isEqual(oldScopeNames, newScopeNames)) {
+ this.locationService.partial(
+ {
+ scopes: newScopeNames,
+ },
+ true
+ );
+ }
+ })
+ );
}
/**
@@ -97,6 +140,14 @@ export class ScopesService implements ScopesContextValue {
public setEnabled = (enabled: boolean) => {
if (this.state.enabled !== enabled) {
this.updateState({ enabled });
+ if (enabled) {
+ this.locationService.partial(
+ {
+ scopes: this.selectorService.state.selectedScopes.map(({ scope }) => scope.metadata.name),
+ },
+ true
+ );
+ }
}
};
@@ -127,4 +178,13 @@ export class ScopesService implements ScopesContextValue {
distinctUntilChanged((prev, curr) => prev.drawerOpened === curr.drawerOpened)
);
}
+
+ /**
+ * Cleanup subscriptions so this can be garbage collected.
+ */
+ public cleanUp() {
+ for (const sub of this.subscriptions) {
+ sub.unsubscribe();
+ }
+ }
}
diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts
index 158be33d5f3..54b3aa6e5fb 100644
--- a/public/app/features/scopes/selector/ScopesSelectorService.ts
+++ b/public/app/features/scopes/selector/ScopesSelectorService.ts
@@ -1,4 +1,4 @@
-import { isEqual } from 'lodash';
+import { isEqual, last } from 'lodash';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
@@ -14,7 +14,11 @@ export interface ScopesSelectorServiceState {
opened: boolean;
loadingNodeName: string | undefined;
nodes: NodesMap;
+
+ // Scopes that are selected and applied.
selectedScopes: SelectedScope[];
+
+ // Representation of what is selected in the tree in the UI. This state may not be yet applied to the selectedScopes.
treeScopes: TreeScope[];
}
@@ -45,29 +49,47 @@ export class ScopesSelectorService extends ScopesServiceBase {
- let nodes = { ...this.state.nodes };
+ if (path.length < 1) {
+ return;
+ }
+
+ // Making a copy as we will be changing this in place and then updating state later.
+ // This though does not make a deep copy so you cannot rely on reference of nested nodes changing.
+ const nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
+ let loadingNodeName = path[0];
- for (let idx = 0; idx < path.length - 1; idx++) {
- currentLevel = currentLevel[path[idx]].nodes;
+ if (path.length > 1) {
+ const pathToParent = path.slice(0, path.length - 1);
+ currentLevel = getNodesAtPath(nodes, pathToParent);
+ loadingNodeName = last(path)!;
}
- const loadingNodeName = path[path.length - 1];
const currentNode = currentLevel[loadingNodeName];
-
const differentQuery = currentNode.query !== query;
currentNode.expanded = expanded;
currentNode.query = query;
if (expanded || differentQuery) {
+ // Means we have to fetch the children of the node
+
this.updateState({ nodes, loadingNodeName });
- // fetchNodeApi does not throw just return empty object
+ // fetchNodeApi does not throw just returns empty object.
+ // Load all the children of the loadingNodeName
const childNodes = await this.apiClient.fetchNode(loadingNodeName, query);
if (loadingNodeName === this.state.loadingNodeName) {
- const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths(
+ const [selectedScopes, treeScopes] = getScopesAndTreeScopesWithPaths(
this.state.selectedScopes,
this.state.treeScopes,
path,
@@ -95,6 +117,14 @@ export class ScopesSelectorService extends ScopesServiceBase {
let treeScopes = [...this.state.treeScopes];
@@ -110,6 +140,8 @@ export class ScopesSelectorService extends ScopesServiceBase scopeName === linkId);
if (selectedIdx === -1) {
+ // We prefetch the scope when clicking on it. This will mean that once the selection is applied in closeAndApply()
+ // we already have all the scopes in cache and don't need to fetch all of them again is multiple requests.
this.apiClient.fetchScope(linkId!);
const selectedFromSameNode =
@@ -133,8 +165,14 @@ export class ScopesSelectorService extends ScopesServiceBase this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
+ /**
+ * Apply the selected scopes. Apart from setting the scopes it also fetches the scope metadata and also loads the
+ * related dashboards.
+ * @param treeScopes The scopes to be applied. If not provided the treeScopes state is used which was populated
+ * before for example by toggling the scopes in the scoped tree UI.
+ */
private setNewScopes = async (treeScopes = this.state.treeScopes) => {
- if (isEqual(treeScopes, this.getTreeScopesFromSelectedScopes(this.state.selectedScopes))) {
+ if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.selectedScopes))) {
return;
}
@@ -142,7 +180,10 @@ export class ScopesSelectorService extends ScopesServiceBase scope.metadata.name));
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
@@ -151,6 +192,9 @@ export class ScopesSelectorService extends ScopesServiceBase this.setNewScopes([]);
+ /**
+ * Opens the scopes selector drawer and loads the root nodes if they are not loaded yet.
+ */
public open = async () => {
if (Object.keys(this.state.nodes[''].nodes).length === 0) {
await this.updateNode([''], true, '');
@@ -159,7 +203,7 @@ export class ScopesSelectorService extends ScopesServiceBase {
- this.updateState({ opened: false, treeScopes: this.getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
+ // Reset the treeScopes if we don't want them actually applied.
+ this.updateState({ opened: false, treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
};
public closeAndApply = () => {
this.updateState({ opened: false });
this.setNewScopes();
};
+}
- private closeNodes = (nodes: NodesMap): NodesMap => {
- return Object.entries(nodes).reduce((acc, [id, node]) => {
- acc[id] = {
- ...node,
- expanded: false,
- nodes: this.closeNodes(node.nodes),
- };
+/**
+ * Creates a deep copy of the node tree with expanded prop set to false.
+ * @param nodes
+ */
+function closeNodes(nodes: NodesMap): NodesMap {
+ return Object.entries(nodes).reduce((acc, [id, node]) => {
+ acc[id] = {
+ ...node,
+ expanded: false,
+ nodes: closeNodes(node.nodes),
+ };
- return acc;
- }, {});
- };
+ return acc;
+ }, {});
+}
- private getTreeScopesFromSelectedScopes = (scopes: SelectedScope[]): TreeScope[] => {
- return scopes.map(({ scope, path }) => ({
- scopeName: scope.metadata.name,
- path,
- }));
- };
+function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
+ return scopes.map(({ scope, path }) => ({
+ scopeName: scope.metadata.name,
+ path,
+ }));
+}
- // helper func to get the selected/tree scopes together with their paths
- // needed to maintain selected scopes in tree for example when navigating
- // between categories or when loading scopes from URL to find the scope's path
- private getScopesAndTreeScopesWithPaths = (
- selectedScopes: SelectedScope[],
- treeScopes: TreeScope[],
- path: string[],
- childNodes: NodesMap
- ): [SelectedScope[], TreeScope[]] => {
- const childNodesArr = Object.values(childNodes);
-
- // Get all scopes without paths
- // We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
- const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
-
- // We search for the path of each scope name without a path
- const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce>((acc, scopeName) => {
- const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
-
- if (possibleParent) {
- acc[scopeName] = [...path, possibleParent.name];
- }
+// helper func to get the selected/tree scopes together with their paths
+// needed to maintain selected scopes in tree for example when navigating
+// between categories or when loading scopes from URL to find the scope's path
+function getScopesAndTreeScopesWithPaths(
+ selectedScopes: SelectedScope[],
+ treeScopes: TreeScope[],
+ path: string[],
+ childNodes: NodesMap
+): [SelectedScope[], TreeScope[]] {
+ const childNodesArr = Object.values(childNodes);
+
+ // Get all scopes without paths
+ // We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
+ const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
+
+ // We search for the path of each scope name without a path
+ const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce>((acc, scopeName) => {
+ const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
+
+ if (possibleParent) {
+ acc[scopeName] = [...path, possibleParent.name];
+ }
- return acc;
- }, {});
+ return acc;
+ }, {});
- // Update the paths of the selected scopes based on what we found
- const newSelectedScopes = selectedScopes.map((selectedScope) => {
- if (selectedScope.path.length > 0) {
- return selectedScope;
- }
+ // Update the paths of the selected scopes based on what we found
+ const newSelectedScopes = selectedScopes.map((selectedScope) => {
+ if (selectedScope.path.length > 0) {
+ return selectedScope;
+ }
- return {
- ...selectedScope,
- path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
- };
- });
+ return {
+ ...selectedScope,
+ path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
+ };
+ });
- // Update the paths of the tree scopes based on what we found
- const newTreeScopes = treeScopes.map((treeScope) => {
- if (treeScope.path.length > 0) {
- return treeScope;
- }
+ // Update the paths of the tree scopes based on what we found
+ const newTreeScopes = treeScopes.map((treeScope) => {
+ if (treeScope.path.length > 0) {
+ return treeScope;
+ }
- return {
- ...treeScope,
- path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
- };
- });
+ return {
+ ...treeScope,
+ path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
+ };
+ });
- return [newSelectedScopes, newTreeScopes];
- };
+ return [newSelectedScopes, newTreeScopes];
}
function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
@@ -269,3 +318,13 @@ function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
return nodes;
}
+
+function getNodesAtPath(nodes: NodesMap, path: string[]): NodesMap {
+ let currentNodes = nodes;
+
+ for (const section of path) {
+ currentNodes = currentNodes[section].nodes;
+ }
+
+ return currentNodes;
+}
diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts
index 8c89619b062..eec2829b144 100644
--- a/public/app/features/scopes/tests/dashboardsList.test.ts
+++ b/public/app/features/scopes/tests/dashboardsList.test.ts
@@ -1,4 +1,4 @@
-import { config } from '@grafana/runtime';
+import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
@@ -65,6 +65,7 @@ describe('Dashboards list', () => {
});
afterEach(async () => {
+ locationService.replace('');
await resetScenes([fetchDashboardsSpy]);
});
diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts
index 251d19c0db7..e430a95ab4f 100644
--- a/public/app/features/scopes/tests/selector.test.ts
+++ b/public/app/features/scopes/tests/selector.test.ts
@@ -1,4 +1,4 @@
-import { config } from '@grafana/runtime';
+import { config, locationService } from '@grafana/runtime';
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopesService } from '../ScopesService';
@@ -36,6 +36,7 @@ describe('Selector', () => {
});
afterEach(async () => {
+ locationService.replace('');
await resetScenes([fetchSelectedScopesSpy, dashboardReloadSpy]);
});
diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts
index 84f047a0768..51ebbc35219 100644
--- a/public/app/features/scopes/tests/tree.test.ts
+++ b/public/app/features/scopes/tests/tree.test.ts
@@ -1,4 +1,4 @@
-import { config } from '@grafana/runtime';
+import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
@@ -74,6 +74,7 @@ describe('Tree', () => {
});
afterEach(async () => {
+ locationService.replace('');
await resetScenes([fetchNodesSpy, fetchScopeSpy]);
});
diff --git a/public/app/features/scopes/tests/utils/render.tsx b/public/app/features/scopes/tests/utils/render.tsx
index 3fa447d952b..391331eb5f1 100644
--- a/public/app/features/scopes/tests/utils/render.tsx
+++ b/public/app/features/scopes/tests/utils/render.tsx
@@ -2,7 +2,7 @@ import { cleanup, waitFor } from '@testing-library/react';
import { KBarProvider } from 'kbar';
import { render } from 'test/test-utils';
-import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getPanelPlugin } from '@grafana/data/test';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { defaultDashboard } from '@grafana/schema';
diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx
index 6cc8bd6f277..cd6b0564436 100644
--- a/public/app/features/search/page/components/SearchResultsTable.tsx
+++ b/public/app/features/search/page/components/SearchResultsTable.tsx
@@ -9,7 +9,8 @@ import { Observable } from 'rxjs';
import { Field, GrafanaTheme2 } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '@grafana/ui';
-import { useTableStyles, TableCell, Trans } from '@grafana/ui/internal';
+import { useTableStyles, TableCell } from '@grafana/ui/internal';
+import { Trans } from 'app/core/internationalization';
import { useCustomFlexLayout } from 'app/features/browse-dashboards/components/customFlexTableLayout';
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts
index 387bbfc66ef..5f252b98936 100644
--- a/public/app/features/search/service/types.ts
+++ b/public/app/features/search/service/types.ts
@@ -2,6 +2,8 @@ import { DataFrameView, SelectableValue } from '@grafana/data';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { PermissionLevelString } from 'app/types';
+import { ManagerKind } from '../../apiserver/types';
+
export interface FacetField {
field: string;
count?: number;
@@ -46,6 +48,7 @@ export interface DashboardQueryResult {
// debugging fields
score: number;
explain: {};
+ managedBy?: ManagerKind;
// enterprise sends extra properties through for sorting (views, errors, etc)
[key: string]: unknown;
@@ -91,4 +94,5 @@ export interface GrafanaSearcher {
export interface NestedFolderDTO {
uid: string;
title: string;
+ managedBy?: ManagerKind;
}
diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts
index 85c0168e480..2bd9f900360 100644
--- a/public/app/features/search/service/utils.ts
+++ b/public/app/features/search/service/utils.ts
@@ -106,6 +106,7 @@ export function queryResultToViewItem(
title: item.name,
url: item.url,
tags: item.tags ?? [],
+ managedBy: item.managedBy,
};
// Set enterprise sort value property
diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts
index 33151cc7e95..39f1d8d1cf0 100644
--- a/public/app/features/search/types.ts
+++ b/public/app/features/search/types.ts
@@ -2,6 +2,8 @@ import { Action } from 'redux';
import { WithAccessControlMetadata } from '@grafana/data';
+import { ManagerKind } from '../apiserver/types';
+
import { QueryResponse } from './service/types';
export enum DashboardSearchItemType {
@@ -80,6 +82,7 @@ export interface DashboardViewItem {
// For enterprise sort options
sortMeta?: number | string; // value sorted by
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
+ managedBy?: ManagerKind;
}
export interface SearchAction extends Action {
diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx
index 27246e3a4c9..6668ec5d768 100644
--- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx
@@ -6,6 +6,7 @@ import { NavModelItem, getTimeZone } from '@grafana/data';
import { Button, ConfirmModal, IconButton, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
+import { Trans, t } from 'app/core/internationalization';
import { AccessControlAction, ApiKey, ServiceAccountDTO, StoreState } from 'app/types';
import { ServiceAccountPermissions } from './ServiceAccountPermissions';
@@ -143,7 +144,9 @@ export const ServiceAccountPageUnconnected = ({
onClick={showDeleteServiceAccountModal(true)}
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete)}
>
- Delete service account
+
+ Delete service account
+
{serviceAccount.isDisabled ? (
- Enable service account
+
+ Enable service account
+
) : (
- Disable service account
+
+ Disable service account
+
)}
@@ -180,7 +187,9 @@ export const ServiceAccountPageUnconnected = ({
)}
- Tokens
+
+ Tokens
+
{!serviceAccount.isExternal && (
setIsTokenModalOpen(true)}
@@ -188,7 +197,9 @@ export const ServiceAccountPageUnconnected = ({
key="add-service-account-token"
icon="plus"
>
- Add service account token
+
+ Add service account token
+
)}
@@ -207,7 +218,10 @@ export const ServiceAccountPageUnconnected = ({
onRoleChange(newRole, original)}
@@ -210,17 +211,17 @@ const getActionsCell = (
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !original.tokens && (
onAddTokenClick(original)} disabled={original.isDisabled}>
- Add token
+ Add token
)}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original) &&
(original.isDisabled ? (
onEnable(original)}>
- Enable
+ Enable
) : (
onDisable(original)}>
- Disable
+ Disable
))}
diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
index 5b8ede18148..bfdd29a08a5 100644
--- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
@@ -197,7 +197,9 @@ export const ServiceAccountsListPageUnconnected = ({
<>
{!noServiceAccountsCreated && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && (
- Add service account
+
+ Add service account
+
)}
>
@@ -208,7 +210,10 @@ export const ServiceAccountsListPageUnconnected = ({
@@ -277,13 +282,19 @@ export const ServiceAccountsListPageUnconnected = ({
: ''
}?`}
confirmText="Delete"
- title="Delete service account"
+ title={t(
+ 'serviceaccounts.service-accounts-list-page-unconnected.title-delete-service-account',
+ 'Delete service account'
+ )}
onConfirm={onServiceAccountRemove}
onDismiss={onRemoveModalClose}
/>
-
+
{isWithExpirationDate && (
-
+
- Generate token
+ Generate token
) : (
<>
@@ -143,16 +147,18 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
icon="copy"
getText={() => token}
>
- Copy clipboard
+ Copy to clipboard
token} onClipboardCopy={onCloseInternal}>
- Copy to clipboard and close
+
+ Copy to clipboard and close
+
- Close
+ Close
>
diff --git a/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx
index 27e17a4b6cc..9b86fd23956 100644
--- a/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx
+++ b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx
@@ -5,6 +5,7 @@ import { GrafanaTheme2, OrgRole, TimeZone, dateTimeFormat } from '@grafana/data'
import { Label, TextLink, useStyles2 } from '@grafana/ui';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { contextSrv } from 'app/core/core';
+import { Trans, t } from 'app/core/internationalization';
import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types';
import { ServiceAccountProfileRow } from './ServiceAccountProfileRow';
@@ -47,38 +48,46 @@ export function ServiceAccountProfile({ serviceAccount, timeZone, onChange }: Pr
return (
-
Information
+
+ Information
+
{serviceAccount.id && (
)}
-
+
{serviceAccount.isExternal && serviceAccount.requiredBy && (
- Used by
+
+ Used by
+
{serviceAccount.requiredBy}
diff --git a/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx
index bdbb0864e7a..2768644b771 100644
--- a/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx
+++ b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx
@@ -1,6 +1,7 @@
import { Label } from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
+import { t } from 'app/core/internationalization';
import { OrgRolePicker } from 'app/features/admin/OrgRolePicker';
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
@@ -38,7 +39,7 @@ export const ServiceAccountRoleRow = ({ label, serviceAccount, roleOptions, onRo
- Name
- Expires
- Created
- Last used at
+
+ Name
+
+
+ Expires
+
+
+ Created
+
+
+ Last used at
+
@@ -96,7 +105,11 @@ interface TokenExpirationProps {
const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => {
const styles = useStyles2(getStyles);
if (!token.expiration) {
- return Never ;
+ return (
+
+ Never
+
+ );
}
if (token.secondsUntilExpiration) {
return (
diff --git a/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx
index 34f35945b43..d5d9fbe02ce 100644
--- a/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx
+++ b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx
@@ -7,6 +7,7 @@ import { Button, Icon, IconButton, Stack, useStyles2 } from '@grafana/ui';
import { SkeletonComponent, attachSkeleton } from '@grafana/ui/unstable';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
+import { t, Trans } from 'app/core/internationalization';
import { OrgRolePicker } from 'app/features/admin/OrgRolePicker';
import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types';
@@ -91,7 +92,7 @@ const ServiceAccountListItemComponent = memo(
) : (
onRoleChange(newRole, serviceAccount)}
@@ -102,7 +103,7 @@ const ServiceAccountListItemComponent = memo(
@@ -122,17 +123,17 @@ const ServiceAccountListItemComponent = memo(
disabled={serviceAccount.isDisabled}
className={styles.actionButton}
>
- Add token
+
Add token
)}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount) &&
(serviceAccount.isDisabled ? (
onEnable(serviceAccount)} className={styles.actionButton}>
- Enable
+ Enable
) : (
onDisable(serviceAccount)} className={styles.actionButton}>
- Disable
+ Disable
))}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && (
diff --git a/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx b/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx
index faf7ef9542b..091159b1b35 100644
--- a/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx
+++ b/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx
@@ -7,6 +7,7 @@ import {
AdHocFiltersVariable,
} from '@grafana/scenes';
import { Button } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { reportExploreMetrics } from '../interactions';
import { VAR_OTEL_AND_METRIC_FILTERS, VAR_OTEL_GROUP_LEFT, VAR_OTEL_RESOURCES } from '../shared';
@@ -87,7 +88,7 @@ export class AddToFiltersGraphAction extends SceneObjectBase
- Add to filters
+ Add to filters
);
};
diff --git a/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx b/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx
index e0a145416ad..f64aa66e458 100644
--- a/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx
+++ b/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx
@@ -2,6 +2,7 @@ import { ChangeEvent } from 'react';
import { BusEventBase } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
+import { t } from 'app/core/internationalization';
import { ByFrameRepeater } from './ByFrameRepeater';
import { LabelBreakdownScene } from './LabelBreakdownScene';
@@ -34,7 +35,7 @@ export class BreakdownSearchScene extends SceneObjectBase
);
};
diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx
index adc86af8f1c..68205f4ace9 100644
--- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx
+++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx
@@ -28,7 +28,7 @@ import {
} from '@grafana/scenes';
import { DataQuery, SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { Alert, Button, Field, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
-import { Trans } from 'app/core/internationalization';
+import { Trans, t } from 'app/core/internationalization';
import { BreakdownLabelSelector } from '../BreakdownLabelSelector';
import { DataTrail } from '../DataTrail';
@@ -374,14 +374,14 @@ export class LabelBreakdownScene extends SceneObjectBase
-
+
>
)}
{body instanceof LayoutSwitcher && (
-
+
)}
@@ -585,7 +585,7 @@ function buildNormalLayout(
children: [
new SceneFlexItem({
body: new SceneReactObject({
- reactNode: ,
+ reactNode: ,
}),
}),
],
diff --git a/public/app/features/trails/Breakdown/SortByScene.tsx b/public/app/features/trails/Breakdown/SortByScene.tsx
index 8454dfdc63b..c3272bc6818 100644
--- a/public/app/features/trails/Breakdown/SortByScene.tsx
+++ b/public/app/features/trails/Breakdown/SortByScene.tsx
@@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { BusEventBase, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { IconButton, Select, Field, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { Trans } from '../../../core/internationalization';
import { getSortByPreference, setSortByPreference } from '../services/store';
@@ -79,7 +80,10 @@ export class SortByScene extends SceneObjectBase {
name={'info-circle'}
size="sm"
variant={'secondary'}
- tooltip="Sorts values using standard or smart time series calculations."
+ tooltip={t(
+ 'trails.sort-by-scene.tooltip-sorts-values-using-standard-smart-series',
+ 'Sorts values using standard or smart time series calculations.'
+ )}
/>
}
diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx
index 75967191964..d752eb5088a 100644
--- a/public/app/features/trails/DataTrailCard.tsx
+++ b/public/app/features/trails/DataTrailCard.tsx
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
import { Card, IconButton, useStyles2 } from '@grafana/ui';
-import { Trans } from 'app/core/internationalization';
+import { Trans, t } from 'app/core/internationalization';
import { DataTrail } from './DataTrail';
import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore';
@@ -79,7 +79,7 @@ export function DataTrailCard(props: Props) {
key="delete"
name="trash-alt"
className={styles.secondary}
- tooltip="Remove bookmark"
+ tooltip={t('trails.data-trail-card.deleteButton-tooltip-remove-bookmark', 'Remove bookmark')}
onClick={onDelete}
data-testid="deleteButton"
/>
diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx
index 7007b511ad0..88fd63adc96 100644
--- a/public/app/features/trails/DataTrailSettings.tsx
+++ b/public/app/features/trails/DataTrailSettings.tsx
@@ -50,7 +50,9 @@ export class DataTrailSettings extends SceneObjectBase {
return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
evt.stopPropagation()}>
-
Settings
+
+ Settings
+
{topScene instanceof MetricScene && (
diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx
index bfd7af07344..694d73918d1 100644
--- a/public/app/features/trails/DataTrailsHistory.tsx
+++ b/public/app/features/trails/DataTrailsHistory.tsx
@@ -1,8 +1,7 @@
import { css, cx } from '@emotion/css';
import { useMemo } from 'react';
-import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT } from '@grafana/data';
-import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
+import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
SceneComponentProps,
@@ -17,6 +16,7 @@ import {
} from '@grafana/scenes';
import { Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
+import { Trans } from 'app/core/internationalization';
import { RecordHistoryEntryEvent } from 'app/types/events';
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
@@ -306,7 +306,9 @@ export class DataTrailHistory extends SceneObjectBase
{
return (
-
History
+
+ History
+
{steps.map((step, index) => {
let stepType = step.type;
@@ -349,7 +351,7 @@ export function parseTimeTooltip(urlValues: SceneObjectUrlValues): string {
return '';
}
- const range = convertRawToRange({
+ const range = rangeUtil.convertRawToRange({
from: urlValues.from,
to: urlValues.to,
});
diff --git a/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts b/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts
index 673cb2d5348..8d7e5418b3a 100644
--- a/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts
+++ b/public/app/features/trails/Integrations/logs/lokiRecordingRules.test.ts
@@ -1,7 +1,7 @@
import { of } from 'rxjs';
import type { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
-import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getMockPlugin } from '@grafana/data/test';
import * as runtime from '@grafana/runtime';
import { MetricsLogsConnector } from './base';
diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx
index 8014f3c2bc5..4483936e390 100644
--- a/public/app/features/trails/MetricScene.tsx
+++ b/public/app/features/trails/MetricScene.tsx
@@ -13,6 +13,7 @@ import {
SceneVariableSet,
} from '@grafana/scenes';
import { Box, Icon, LinkButton, Stack, Tab, TabsBar, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { getExploreUrl } from '../../core/utils/explore';
@@ -182,18 +183,21 @@ export class MetricActionBar extends SceneObjectBase
{
{
reportExploreMetrics('selected_metric_action_clicked', { action: 'unselect' });
trail.publishEvent(new MetricSelectedEvent(undefined));
}}
>
- Select new metric
+ Select new metric
@@ -215,7 +219,7 @@ export class MetricActionBar extends SceneObjectBase {
variant={'secondary'}
onClick={() => reportExploreMetrics('selected_metric_action_clicked', { action: 'open_from_embedded' })}
>
- Open
+ Open
)}
diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx
index 9cea458adaa..ecb6d767529 100644
--- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx
+++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx
@@ -26,7 +26,7 @@ import {
VariableDependencyConfig,
} from '@grafana/scenes';
import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
-import { Trans } from 'app/core/internationalization';
+import { Trans, t } from 'app/core/internationalization';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
@@ -523,6 +523,10 @@ export class MetricSelectScene extends SceneObjectBase i
const isLoading = metricNamesLoading && children.length === 0;
+ const unableToRetrieveMetricNames = t(
+ 'trails.metric-select-scene.unable-to-retrieve-metric-names',
+ 'Unable to retrieve metric names'
+ );
const blockingMessage = isLoading
? undefined
: missingOtelTargets
@@ -535,7 +539,7 @@ export class MetricSelectScene extends SceneObjectBase i
- Unable to retrieve metric names
+ {unableToRetrieveMetricNames}
{metricNamesWarning}
>
}
@@ -549,7 +553,7 @@ export class MetricSelectScene extends SceneObjectBase i
}
value={metricSearch}
onChange={model.onSearchQueryChange}
@@ -616,7 +620,7 @@ export class MetricSelectScene extends SceneObjectBase i
@@ -625,14 +629,17 @@ export class MetricSelectScene extends SceneObjectBase i
)}
{metricNamesError && (
-
+
We are unable to connect to your data source. Double check your data source URL and credentials.
({metricNamesError})
)}
{metricNamesWarning && !warningDismissed && (
(
- Metrics
- Explore your Prometheus-compatible metrics without writing a query
+
+ Metrics
+
+
+
+ Explore your Prometheus-compatible metrics without writing a query
+
+
);
diff --git a/public/app/features/trails/TrailStore/utils.tsx b/public/app/features/trails/TrailStore/utils.tsx
index 8c1b84d1900..7975fbb3085 100644
--- a/public/app/features/trails/TrailStore/utils.tsx
+++ b/public/app/features/trails/TrailStore/utils.tsx
@@ -1,4 +1,5 @@
import { LinkButton, Stack } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { createSuccessNotification } from '../../../core/copy/appNotification';
import { HOME_ROUTE } from '../shared';
@@ -12,7 +13,7 @@ export function createBookmarkSavedNotification() {
You can view bookmarks under Explore > Metrics
- View bookmarks
+ View bookmarks
);
diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx
index f896c2a5759..51228024888 100644
--- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx
@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { Field, SelectableValue, valueMatchers } from '@grafana/data';
-import { FilterByValueFilter } from '@grafana/data/src/transformations/transformers/filterByValue';
+import { FilterByValueFilter } from '@grafana/data/internal';
import { Button, Select, InlineField, InlineFieldRow, Box } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { valueMatchersUI } from './ValueMatchers/valueMatchersUI';
@@ -76,25 +77,25 @@ export const FilterByValueFilterEditor = (props: Props) => {
return (
-
+
-
+
-
+
diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx
index e9cf39d1497..6c064b2ad0c 100644
--- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx
@@ -1,7 +1,7 @@
import { render, fireEvent } from '@testing-library/react';
import { DataFrame, FieldType, ValueMatcherID, valueMatchers } from '@grafana/data';
-import { FilterByValueMatch, FilterByValueType } from '@grafana/data/src/transformations/transformers/filterByValue';
+import { FilterByValueMatch, FilterByValueType } from '@grafana/data/internal';
import { FilterByValueTransformerEditor } from './FilterByValueTransformerEditor';
diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx
index 683350d47b1..09cdb5f67c4 100644
--- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx
@@ -19,8 +19,9 @@ import {
FilterByValueMatch,
FilterByValueTransformerOptions,
FilterByValueType,
-} from '@grafana/data/src/transformations/transformers/filterByValue';
+} from '@grafana/data/internal';
import { Button, RadioButtonGroup, InlineField, Box } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -101,12 +102,18 @@ export const FilterByValueTransformerEditor = (props: TransformerUIProps
-
+
-
+
@@ -122,7 +129,7 @@ export const FilterByValueTransformerEditor = (props: TransformerUIProps
))}
- Add condition
+ Add condition
diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx
index 6cb7328c59b..780a52ec9a1 100644
--- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
import * as React from 'react';
import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data';
+import { t } from 'app/core/internationalization';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { getVariableSuggestions, numberOrVariableValidator } from '../../utils';
@@ -33,7 +34,7 @@ export function basicMatcherEditor
(
value={value}
error={'Value needs to be a number or a variable'}
onChange={onChangeVariableValue}
- placeholder="Value or variable"
+ placeholder={t('transformers.basic-matcher-editor.placeholder-value-or-variable', 'Value or variable')}
suggestions={getVariableSuggestions()}
/>
);
diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx
index 0dd2c09ff92..36fbc5c07ec 100644
--- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import { ValueMatcherID, RangeValueMatcherOptions } from '@grafana/data';
import { InlineLabel } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { getVariableSuggestions, numberOrVariableValidator } from '../../utils';
@@ -50,16 +51,18 @@ export function rangeMatcherEditor(
value={String(options.from)}
invalid={isInvalid.from}
error={'Value needs to be a number or a variable'}
- placeholder="From"
+ placeholder={t('transformers.range-matcher-editor.placeholder-from', 'From')}
onChange={(val) => onChangeOptionsSuggestions(val, 'from')}
suggestions={suggestions}
/>
- and
+
+ and
+
onChangeOptionsSuggestions(val, 'to')}
/>
diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx
index 451bcf65999..09fc5936cda 100644
--- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx
+++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
import * as React from 'react';
import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data';
+import { t } from 'app/core/internationalization';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { getVariableSuggestions } from '../../utils';
@@ -32,7 +33,7 @@ export function regexMatcherEditor(
invalid={isInvalid}
value={value}
onChange={onChangeVariableValue}
- placeholder="Value or variable"
+ placeholder={t('transformers.regex-matcher-editor.placeholder-value-or-variable', 'Value or variable')}
suggestions={getVariableSuggestions()}
/>
);
diff --git a/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx
index c45de35235b..4ead9f3ea94 100644
--- a/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx
+++ b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx
@@ -4,6 +4,7 @@ import { SelectableValue, StandardEditorProps, VariableOrigin } from '@grafana/d
import { getTemplateSrv } from '@grafana/runtime';
import { HeatmapCalculationBucketConfig, HeatmapCalculationMode } from '@grafana/schema';
import { HorizontalGroup, RadioButtonGroup, ScaleDistribution } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../../utils';
@@ -82,7 +83,7 @@ export const AxisEditor = ({ value, onChange, item }: StandardEditorProps {
onValueChange({ ...value, value: text });
}}
diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts
index 0087b9fe22f..256cd576ebe 100644
--- a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts
+++ b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts
@@ -1,5 +1,4 @@
-import { FieldType } from '@grafana/data';
-import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
+import { FieldType, toDataFrame } from '@grafana/data';
import { HeatmapCalculationOptions } from '@grafana/schema';
import { rowsToCellsHeatmap, calculateHeatmapFromData } from './heatmap';
diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts
index 6a7a29ce69c..ed37d76651e 100644
--- a/public/app/features/transformers/calculateHeatmap/heatmap.ts
+++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts
@@ -15,7 +15,7 @@ import {
TransformationApplicabilityLevels,
TimeRange,
} from '@grafana/data';
-import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
+import { isLikelyAscendingVector } from '@grafana/data/internal';
import {
ScaleDistribution,
HeatmapCellLayout,
diff --git a/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx
index 87ffe52cbb3..e21d28bbd68 100644
--- a/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx
+++ b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx
@@ -10,6 +10,7 @@ import {
TransformerCategory,
} from '@grafana/data';
import { fieldMatchersUI, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { FieldToConfigMappingEditor } from '../fieldToConfigMapping/FieldToConfigMappingEditor';
@@ -54,17 +55,27 @@ export function ConfigFromQueryTransformerEditor({ input, onChange, options }: P
return (
<>
-
+
-
+
-
+
-
+
-
+
)}
-
+
-
+
>
diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CumulativeOptionsEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CumulativeOptionsEditor.tsx
index 1194514bcf4..815484ecf5e 100644
--- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CumulativeOptionsEditor.tsx
+++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CumulativeOptionsEditor.tsx
@@ -1,10 +1,7 @@
import { ReducerID, SelectableValue } from '@grafana/data';
-import {
- CalculateFieldMode,
- CalculateFieldTransformerOptions,
- CumulativeOptions,
-} from '@grafana/data/src/transformations/transformers/calculateField';
+import { CalculateFieldMode, CalculateFieldTransformerOptions, CumulativeOptions } from '@grafana/data/internal';
import { InlineField, Select, StatsPicker } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { LABEL_WIDTH } from './constants';
@@ -40,16 +37,19 @@ export const CumulativeOptionsEditor = (props: {
return (
<>
-
+
-
+
-
+
>
diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/ReduceRowOptionsEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/ReduceRowOptionsEditor.tsx
index c48870fc36b..1bf1c21023f 100644
--- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/ReduceRowOptionsEditor.tsx
+++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/ReduceRowOptionsEditor.tsx
@@ -1,9 +1,7 @@
import { ReducerID } from '@grafana/data';
-import {
- CalculateFieldTransformerOptions,
- ReduceOptions,
-} from '@grafana/data/src/transformations/transformers/calculateField';
+import { CalculateFieldTransformerOptions, ReduceOptions } from '@grafana/data/internal';
import { FilterPill, HorizontalGroup, InlineField, StatsPicker } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { LABEL_WIDTH } from './constants';
@@ -47,7 +45,11 @@ export const ReduceRowOptionsEditor = (props: {
return (
<>
-
+
{names.map((o, i) => {
return (
@@ -63,7 +65,10 @@ export const ReduceRowOptionsEditor = (props: {
})}
-
+
-
+
-
+
-
+
-
+
-
+
-
+
{frameNameMode === ConcatenateFrameNameMode.Label && (
-
-
+
+
)}
diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx
index c7d0f4f2f39..fe58688236e 100644
--- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx
@@ -12,12 +12,10 @@ import {
TransformerCategory,
getTimeZones,
} from '@grafana/data';
-import {
- ConvertFieldTypeOptions,
- ConvertFieldTypeTransformerOptions,
-} from '@grafana/data/src/transformations/transformers/convertFieldType';
+import { ConvertFieldTypeOptions, ConvertFieldTypeTransformerOptions } from '@grafana/data/internal';
import { Button, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { allFieldTypeIconOptions, FieldNamePicker } from '@grafana/ui/internal';
+import { t } from 'app/core/internationalization';
import { findField } from 'app/features/dimensions';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -153,7 +151,7 @@ export const ConvertFieldTypeTransformerEditor = ({
{c.destinationType === FieldType.time && (
{(c.joinWith?.length || targetField?.type === FieldType.other) && (
-
+
)}
{targetField?.type === FieldType.time && (
<>
-
+
-
+
>
diff --git a/public/app/features/transformers/editors/EnumMappingEditor.tsx b/public/app/features/transformers/editors/EnumMappingEditor.tsx
index 09f1822302c..e0f2f535079 100644
--- a/public/app/features/transformers/editors/EnumMappingEditor.tsx
+++ b/public/app/features/transformers/editors/EnumMappingEditor.tsx
@@ -4,8 +4,9 @@ import { isEqual } from 'lodash';
import { useEffect, useState } from 'react';
import { DataFrame, EnumFieldConfig, GrafanaTheme2 } from '@grafana/data';
-import { ConvertFieldTypeTransformerOptions } from '@grafana/data/src/transformations/transformers/convertFieldType';
+import { ConvertFieldTypeTransformerOptions } from '@grafana/data/internal';
import { Button, HorizontalGroup, InlineFieldRow, useStyles2, VerticalGroup } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import EnumMappingRow from './EnumMappingRow';
@@ -121,10 +122,12 @@ export const EnumMappingEditor = ({ input, options, transformIndex, onChange }:
generateEnumValues()} className={styles.button}>
- Generate enum values from data
+
+ Generate enum values from data
+
onAddEnumRow()} className={styles.button}>
- Add enum value
+ Add enum value
diff --git a/public/app/features/transformers/editors/EnumMappingRow.tsx b/public/app/features/transformers/editors/EnumMappingRow.tsx
index b3dee5ac3fa..eab72e2f62a 100644
--- a/public/app/features/transformers/editors/EnumMappingRow.tsx
+++ b/public/app/features/transformers/editors/EnumMappingRow.tsx
@@ -4,6 +4,7 @@ import { FormEvent, useState, KeyboardEvent, useRef, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Input, IconButton, HorizontalGroup, FieldValidationMessage, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
type EnumMappingRowProps = {
transformIndex: number;
@@ -116,8 +117,11 @@ const EnumMappingRow = ({
name="trash-alt"
onClick={onRemoveButtonClick}
data-testid="remove-enum-row"
- aria-label="Delete enum row"
- tooltip="Delete"
+ aria-label={t(
+ 'transformers.enum-mapping-row.remove-enum-row-aria-label-delete-enum-row',
+ 'Delete enum row'
+ )}
+ tooltip={t('transformers.enum-mapping-row.remove-enum-row-tooltip-delete', 'Delete')}
/>
diff --git a/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx
index 8ac19f37e7f..52df4e66431 100644
--- a/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx
@@ -11,9 +11,10 @@ import {
TransformerCategory,
SelectableValue,
} from '@grafana/data';
-import { FilterFieldsByNameTransformerOptions } from '@grafana/data/src/transformations/transformers/filterByName';
+import { FilterFieldsByNameTransformerOptions } from '@grafana/data/internal';
import { getTemplateSrv } from '@grafana/runtime/src/services';
import { Input, FilterPill, InlineFieldRow, InlineField, InlineSwitch, Select } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -198,14 +199,14 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
const { options, selected, isRegexValid } = this.state;
return (
-
-
+
+
{this.state.byVariable ? (
-
+
) : (
-
+
this.setState({ regex: e.currentTarget.value })}
onBlur={this.onInputBlur}
diff --git a/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx
index f67a7cb2b95..05b9e196b83 100644
--- a/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx
@@ -6,7 +6,7 @@ import {
TransformerCategory,
FrameMatcherID,
} from '@grafana/data';
-import { FilterFramesByRefIdTransformerOptions } from '@grafana/data/src/transformations/transformers/filterByRefId';
+import { FilterFramesByRefIdTransformerOptions } from '@grafana/data/internal';
import { FrameMultiSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor';
import { getTransformationContent } from '../docs/getTransformationContent';
diff --git a/public/app/features/transformers/editors/FormatStringTransformerEditor.tsx b/public/app/features/transformers/editors/FormatStringTransformerEditor.tsx
index e208d67976e..2932f54ab13 100644
--- a/public/app/features/transformers/editors/FormatStringTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/FormatStringTransformerEditor.tsx
@@ -12,13 +12,11 @@ import {
FieldNamePickerConfigSettings,
TransformerCategory,
} from '@grafana/data';
-import {
- FormatStringOutput,
- FormatStringTransformerOptions,
-} from '@grafana/data/src/transformations/transformers/formatString';
+import { FormatStringOutput, FormatStringTransformerOptions } from '@grafana/data/internal';
import { Select, InlineFieldRow, InlineField } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/internal';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
+import { t } from 'app/core/internationalization';
const fieldNamePickerSettings: StandardEditorsRegistryItem = {
settings: {
@@ -93,14 +91,17 @@ function FormatStringTransfomerEditor({
/>
-
+
{options.outputFormat === FormatStringOutput.Substring && (
-
+
diff --git a/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx b/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx
index 4c2a646d93f..3e8fd6b3b3f 100644
--- a/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx
@@ -9,8 +9,9 @@ import {
getFieldDisplayName,
PluginState,
} from '@grafana/data';
-import { FormatTimeTransformerOptions } from '@grafana/data/src/transformations/transformers/formatTime';
+import { FormatTimeTransformerOptions } from '@grafana/data/internal';
import { Select, InlineFieldRow, InlineField, Input } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { getTimezoneOptions } from '../utils';
@@ -69,18 +70,24 @@ export function FormatTimeTransfomerEditor({
return (
<>
-
+
@@ -95,7 +102,14 @@ export function FormatTimeTransfomerEditor({
>
-
+
diff --git a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx
index ca4386f541f..15a26e4ad6f 100644
--- a/public/app/features/transformers/editors/GroupByTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx
@@ -11,12 +11,9 @@ import {
TransformerCategory,
GrafanaTheme2,
} from '@grafana/data';
-import {
- GroupByFieldOptions,
- GroupByOperationID,
- GroupByTransformerOptions,
-} from '@grafana/data/src/transformations/transformers/groupBy';
+import { GroupByFieldOptions, GroupByOperationID, GroupByTransformerOptions } from '@grafana/data/internal';
import { useTheme2, Select, StatsPicker, InlineField, Stack, Alert } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { useAllFieldNamesFromDataFrames } from '../utils';
@@ -106,13 +103,19 @@ export const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }:
-
+
{config?.operation === GroupByOperationID.aggregate && (
{
diff --git a/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx
index 17170be06cc..1e4252d9b2f 100644
--- a/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx
@@ -16,12 +16,11 @@ import {
GroupByFieldOptions,
GroupByOperationID,
GroupByTransformerOptions,
-} from '@grafana/data/src/transformations/transformers/groupBy';
-import {
GroupToNestedTableTransformerOptions,
SHOW_NESTED_HEADERS_DEFAULT,
-} from '@grafana/data/src/transformations/transformers/groupToNestedTable';
+} from '@grafana/data/internal';
import { useTheme2, Select, StatsPicker, InlineField, Field, Switch, Alert, Stack } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { useAllFieldNamesFromDataFrames } from '../utils';
@@ -103,7 +102,10 @@ export const GroupToNestedTableTransformerEditor = ({
))}
@@ -135,13 +137,19 @@ export const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }:
-
+
{config?.operation === GroupByOperationID.aggregate && (
{
diff --git a/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx
index 878b9b879d8..ae4c1c96309 100644
--- a/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx
@@ -12,6 +12,7 @@ import {
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { useAllFieldNamesFromDataFrames } from '../utils';
@@ -79,7 +80,10 @@ export const GroupingToMatrixTransformerEditor = ({
return (
<>
-
+
-
+
-
+
-
+
diff --git a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx
index a257f02b0af..93da2d93f9d 100644
--- a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx
@@ -7,11 +7,9 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import {
- histogramFieldInfo,
- HistogramTransformerInputs,
-} from '@grafana/data/src/transformations/transformers/histogram';
+import { histogramFieldInfo, HistogramTransformerInputs } from '@grafana/data/internal';
import { InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { SuggestionsInput } from '../suggestionsInput/SuggestionsInput';
@@ -88,7 +86,7 @@ export const HistogramTransformerEditor = ({
diff --git a/public/app/features/transformers/editors/JoinByFieldTransformerEditor.tsx b/public/app/features/transformers/editors/JoinByFieldTransformerEditor.tsx
index cccf062d843..e111f4ad6b7 100644
--- a/public/app/features/transformers/editors/JoinByFieldTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/JoinByFieldTransformerEditor.tsx
@@ -8,10 +8,11 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import { JoinByFieldOptions, JoinMode } from '@grafana/data/src/transformations/transformers/joinByField';
+import { JoinByFieldOptions, JoinMode } from '@grafana/data/internal';
import { getTemplateSrv } from '@grafana/runtime';
import { Select, InlineFieldRow, InlineField } from '@grafana/ui';
import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/internal';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -68,16 +69,26 @@ export function SeriesToFieldsTransformerEditor({ input, options, onChange }: Tr
return (
<>
-
+
-
+
diff --git a/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx
index e41017a51a2..cafb15daaa8 100644
--- a/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx
@@ -8,11 +8,9 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import {
- LabelsToFieldsMode,
- LabelsToFieldsOptions,
-} from '@grafana/data/src/transformations/transformers/labelsToFields';
+import { LabelsToFieldsMode, LabelsToFieldsOptions } from '@grafana/data/internal';
import { InlineField, InlineFieldRow, RadioButtonGroup, Select, FilterPill, Stack } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -102,14 +100,20 @@ export const LabelsAsFieldsTransformerEditor = ({
diff --git a/public/app/features/transformers/editors/MergeTransformerEditor.tsx b/public/app/features/transformers/editors/MergeTransformerEditor.tsx
index cd55fbd5543..dd6c02481be 100644
--- a/public/app/features/transformers/editors/MergeTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/MergeTransformerEditor.tsx
@@ -5,15 +5,22 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import { MergeTransformerOptions } from '@grafana/data/src/transformations/transformers/merge';
+import { MergeTransformerOptions } from '@grafana/data/internal';
import { FieldValidationMessage } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
export const MergeTransformerEditor = ({ input, options, onChange }: TransformerUIProps) => {
if (input.length <= 1) {
// Show warning that merge is useless only apply on a single frame
- return Merge has no effect when applied on a single frame. ;
+ return (
+
+
+ Merge has no effect when applied on a single frame.
+
+
+ );
}
return null;
};
diff --git a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx
index ef640a79dad..e938af3e98b 100644
--- a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx
@@ -10,8 +10,7 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order';
-import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize';
+import { createOrderFieldsComparer, OrganizeFieldsTransformerOptions } from '@grafana/data/internal';
import {
Input,
IconButton,
@@ -23,6 +22,7 @@ import {
Text,
Box,
} from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { useAllFieldNamesFromDataFrames } from '../utils';
@@ -165,7 +165,15 @@ const DraggableFieldName = ({
-
+
-
+
v.value === options.mode) || modes[0]}
@@ -66,13 +72,13 @@ export const ReduceTransformerEditor = ({ options, onChange }: TransformerUIProp
/>
{
@@ -84,12 +90,20 @@ export const ReduceTransformerEditor = ({ options, onChange }: TransformerUIProp
/>
{options.mode === ReduceTransformerMode.ReduceFields && (
-
+
)}
{options.mode !== ReduceTransformerMode.ReduceFields && (
-
+
)}
diff --git a/public/app/features/transformers/editors/RenameByRegexTransformer.tsx b/public/app/features/transformers/editors/RenameByRegexTransformer.tsx
index d192c4a9ee0..f2293770398 100644
--- a/public/app/features/transformers/editors/RenameByRegexTransformer.tsx
+++ b/public/app/features/transformers/editors/RenameByRegexTransformer.tsx
@@ -8,8 +8,9 @@ import {
stringToJsRegex,
TransformerCategory,
} from '@grafana/data';
-import { RenameByRegexTransformerOptions } from '@grafana/data/src/transformations/transformers/renameByRegex';
+import { RenameByRegexTransformerOptions } from '@grafana/data/internal';
import { InlineField, Input } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -83,22 +84,31 @@ export class RenameByRegexTransformerEditor extends React.PureComponent<
return (
<>
-
+
{
return (
-
+
{
onSortChange(index, { ...s, field: v.value! });
}}
/>
-
+
{
diff --git a/public/app/features/transformers/editors/TransposeTransformerEditor.tsx b/public/app/features/transformers/editors/TransposeTransformerEditor.tsx
index 4190d385e48..4acb09875b9 100644
--- a/public/app/features/transformers/editors/TransposeTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/TransposeTransformerEditor.tsx
@@ -5,8 +5,9 @@ import {
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
-import { TransposeTransformerOptions } from '@grafana/data/src/transformations/transformers/transpose';
+import { TransposeTransformerOptions } from '@grafana/data/internal';
import { InlineField, InlineFieldRow, Input } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIProps) => {
return (
@@ -14,7 +15,7 @@ export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIPr
onChange({ ...options, firstFieldName: e.currentTarget.value })}
width={25}
@@ -24,7 +25,7 @@ export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIPr
onChange({ ...options, restFieldsName: e.currentTarget.value })}
width={25}
diff --git a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx
index 04ecc1ad0da..957e48d49d7 100644
--- a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx
+++ b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx
@@ -11,6 +11,7 @@ import {
} from '@grafana/data';
import { InlineField, InlineFieldRow, Select, InlineSwitch, Input, Combobox, ComboboxOption } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/internal';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -124,12 +125,18 @@ export const extractFieldsTransformerEditor = ({
)}
{options.format === FieldExtractorID.Delimiter && (
-
+
diff --git a/public/app/features/transformers/extractFields/components/JSONPathEditor.tsx b/public/app/features/transformers/extractFields/components/JSONPathEditor.tsx
index a25ab5367e6..a89b7ae8237 100644
--- a/public/app/features/transformers/extractFields/components/JSONPathEditor.tsx
+++ b/public/app/features/transformers/extractFields/components/JSONPathEditor.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react';
import * as React from 'react';
import { Button, InlineField, InlineFieldRow, IconButton, Input } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { JSONPath } from '../types';
@@ -51,15 +52,18 @@ export function JSONPathEditor({ options, onChange }: Props) {
paths.map((path: JSONPath, key: number) => (
-
+
) => onJSONPathChange(event, key, 'path')}
value={path.path}
- placeholder='A valid json path, e.g. "object.value1" or "object.value2[0]"'
+ placeholder={t(
+ 'transformers.jsonpath-editor.placeholder-valid-objectvalue',
+ 'A valid json path, e.g. "object.value1" or "object.value2[0]"'
+ )}
/>
-
+
- removeJSONPath(key)} name={'trash-alt'} tooltip="Remove path" />
+ removeJSONPath(key)}
+ name={'trash-alt'}
+ tooltip={t('transformers.jsonpath-editor.tooltip-remove-path', 'Remove path')}
+ />
))}
addJSONPath()} variant={'secondary'}>
- Add path
+ Add path
@@ -96,7 +104,9 @@ const getTooltips = () => {
A valid path of an json object.
- JSON Value:
+
+ JSON Value:
+
diff --git a/public/app/features/transformers/extractFields/extractFields.test.ts b/public/app/features/transformers/extractFields/extractFields.test.ts
index a6617aecea9..863b89163ca 100644
--- a/public/app/features/transformers/extractFields/extractFields.test.ts
+++ b/public/app/features/transformers/extractFields/extractFields.test.ts
@@ -5,10 +5,9 @@ import {
Field,
FieldType,
transformDataFrame,
+ toDataFrame,
} from '@grafana/data';
-import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
-import { SortByTransformerOptions, sortByTransformer } from '@grafana/data/src/transformations/transformers/sortBy';
-import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry';
+import { mockTransformationsRegistry, SortByTransformerOptions, sortByTransformer } from '@grafana/data/internal';
import { extractFieldsTransformer } from './extractFields';
import { ExtractFieldsOptions, FieldExtractorID } from './types';
diff --git a/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx
index be8edf7be09..d57d3f4157e 100644
--- a/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx
+++ b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx
@@ -3,6 +3,7 @@ import { capitalize } from 'lodash';
import { DataFrame, getFieldDisplayName, GrafanaTheme2, ReducerID, SelectableValue } from '@grafana/data';
import { Select, StatsPicker, useStyles2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
import {
configMapHandlers,
@@ -85,10 +86,24 @@ export function FieldToConfigMappingEditor({ frame, mappings, onChange, withRedu
- Field
- Use as
- {withReducers && Select }
- {hasAdditionalSettings && Additional settings }
+
+ Field
+
+
+ Use as
+
+ {withReducers && (
+
+ Select
+
+ )}
+ {hasAdditionalSettings && (
+
+
+ Additional settings
+
+
+ )}
diff --git a/public/app/features/transformers/joinByLabels/JoinByLabelsTransformerEditor.tsx b/public/app/features/transformers/joinByLabels/JoinByLabelsTransformerEditor.tsx
index 7f1ff442f37..a11dc511dcb 100644
--- a/public/app/features/transformers/joinByLabels/JoinByLabelsTransformerEditor.tsx
+++ b/public/app/features/transformers/joinByLabels/JoinByLabelsTransformerEditor.tsx
@@ -9,6 +9,7 @@ import {
TransformerCategory,
} from '@grafana/data';
import { Alert, HorizontalGroup, InlineField, InlineFieldRow, Select, ValuePicker } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
import { getDistinctLabels } from '../utils';
@@ -29,9 +30,23 @@ export function JoinByLabelsTransformerEditor({ input, options, onChange }: Prop
}
if (!input.length) {
- warn = No input (or labels) found ;
+ warn = (
+
+
+ No input (or labels) found
+
+
+ );
} else if (distinct.size === 0) {
- warn = The input does not contain any labels ;
+ warn = (
+
+
+ The input does not contain any labels
+
+
+ );
}
// Show the selected values
@@ -97,7 +112,10 @@ export function JoinByLabelsTransformerEditor({ input, options, onChange }: Prop
invalid={!Boolean(options.value?.length)}
label={'Value'}
labelWidth={labelWidth}
- tooltip="Select the label indicating the values name"
+ tooltip={t(
+ 'transformers.join-by-labels-transformer-editor.tooltip-select-label-indicating-values',
+ 'Select the label indicating the values name'
+ )}
>
1) {
- return Partition by values only works with a single frame. ;
+ return (
+
+
+ Partition by values only works with a single frame.
+
+
+ );
}
const fieldNames = [...new Set(options.fields)];
@@ -99,7 +106,11 @@ export function PartitionByValuesEditor({
return (
-
+
{fieldNames.map((name) => (
removeField(name)}>
@@ -112,7 +123,7 @@ export function PartitionByValuesEditor({
size="md"
options={selectOptions}
onChange={addField}
- label="Select field"
+ label={t('transformers.partition-by-values-editor.label-select-field', 'Select field')}
icon="plus"
/>
)}
diff --git a/public/app/features/transformers/partitionByValues/partitionByValues.ts b/public/app/features/transformers/partitionByValues/partitionByValues.ts
index 80c349fe762..4ab8d360cbe 100644
--- a/public/app/features/transformers/partitionByValues/partitionByValues.ts
+++ b/public/app/features/transformers/partitionByValues/partitionByValues.ts
@@ -8,8 +8,7 @@ import {
DataTransformContext,
FieldMatcher,
} from '@grafana/data';
-import { getMatcherConfig } from '@grafana/data/src/transformations/transformers/filterByName';
-import { noopTransformer } from '@grafana/data/src/transformations/transformers/noop';
+import { getMatcherConfig, noopTransformer } from '@grafana/data/internal';
import { partition } from './partition';
diff --git a/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx b/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx
index a38f14aea32..9c104cdca92 100644
--- a/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx
+++ b/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx
@@ -10,6 +10,7 @@ import {
TransformerCategory,
} from '@grafana/data';
import { InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
+import { Trans, t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -21,10 +22,20 @@ const wideInfo = {
description: 'Creates a single frame joined by time',
info: (
- Single frame
- 1st field is shared time field
- Time in ascending order
- Multiple value fields of any type
+
+ Single frame
+
+
+ 1st field is shared time field
+
+
+ Time in ascending order
+
+
+
+ Multiple value fields of any type
+
+
),
};
@@ -35,11 +46,21 @@ const multiInfo = {
description: 'Creates a new frame for each time/number pair',
info: (
- Multiple frames
+
+ Multiple frames
+
Each frame has two fields: time, value
- Time in ascending order
- String values are represented as labels
- All values are numeric
+
+ Time in ascending order
+
+
+
+ String values are represented as labels
+
+
+
+ All values are numeric
+
),
};
@@ -50,11 +71,21 @@ const longInfo = {
description: 'Convert each frame to long format',
info: (
- Single frame
- 1st field is time field
- Time in ascending order, but may have duplicates
+
+ Single frame
+
+
+ 1st field is time field
+
+
+
+ Time in ascending order, but may have duplicates
+
+
String values are represented as separate fields rather than as labels
- Multiple value fields may exist
+
+ Multiple value fields may exist
+
),
};
@@ -78,7 +109,7 @@ export function PrepareTimeSeriesEditor(props: TransformerUIProps
-
+
-
+
{(formats.find((v) => v.value === options.format) || formats[0]).info}
diff --git a/public/app/features/transformers/regression/regressionEditor.tsx b/public/app/features/transformers/regression/regressionEditor.tsx
index 4044035562d..85207f4b75d 100644
--- a/public/app/features/transformers/regression/regressionEditor.tsx
+++ b/public/app/features/transformers/regression/regressionEditor.tsx
@@ -12,6 +12,7 @@ import {
import { InlineField, Select } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/internal';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -74,7 +75,10 @@ export const RegressionTransformerEditor = ({
return (
<>
-
+
-
+
-
+
{
@@ -103,7 +113,11 @@ export const RegressionTransformerEditor = ({
options={modelTypeOptions}
>
-
+
{
@@ -112,7 +126,10 @@ export const RegressionTransformerEditor = ({
>
{options.modelType === ModelType.polynomial && (
-
+
value={options.degree ?? DEFAULTS.degree}
options={[
diff --git a/public/app/features/transformers/spatial/optionsHelper.tsx b/public/app/features/transformers/spatial/optionsHelper.tsx
index 49f77cf7927..e493dfc7d0c 100644
--- a/public/app/features/transformers/spatial/optionsHelper.tsx
+++ b/public/app/features/transformers/spatial/optionsHelper.tsx
@@ -1,8 +1,7 @@
import { set, get as lodashGet } from 'lodash';
import { StandardEditorContext, TransformerUIProps, PanelOptionsEditorBuilder } from '@grafana/data';
-import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
-import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
+import { NestedValueAccess, PanelOptionsSupplier } from '@grafana/data/internal';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
diff --git a/public/app/features/transformers/spatial/spatialTransformer.test.ts b/public/app/features/transformers/spatial/spatialTransformer.test.ts
index b36315fefe5..196a6b3680d 100644
--- a/public/app/features/transformers/spatial/spatialTransformer.test.ts
+++ b/public/app/features/transformers/spatial/spatialTransformer.test.ts
@@ -1,6 +1,5 @@
-import { FieldMatcherID, fieldMatchers, FieldType } from '@grafana/data';
-import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
-import { DataTransformerID } from '@grafana/data/src/transformations/transformers/ids';
+import { toDataFrame, FieldMatcherID, fieldMatchers, FieldType } from '@grafana/data';
+import { DataTransformerID } from '@grafana/data/internal';
import { frameAsGazetter } from 'app/features/geo/gazetteer/gazetteer';
describe('spatial transformer', () => {
diff --git a/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
index b8387e6889a..7e09fc1d1c5 100644
--- a/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
+++ b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
@@ -12,6 +12,7 @@ import {
isTimeSeriesField,
} from '@grafana/data';
import { InlineFieldRow, InlineField, StatsPicker, Select, InlineLabel } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -89,7 +90,7 @@ export function TimeSeriesTableTransformEditor({
{`Trend #${refId}`}
-
+
{
- Sign in
+ Sign in
diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx
index 5a4fa4b2e8b..092cd157c53 100644
--- a/public/app/features/users/UsersActionBar.tsx
+++ b/public/app/features/users/UsersActionBar.tsx
@@ -4,6 +4,7 @@ import { reportInteraction } from '@grafana/runtime';
import { RadioButtonGroup, LinkButton, FilterInput, InlineField } from '@grafana/ui';
import config from 'app/core/config';
import { contextSrv } from 'app/core/core';
+import { t, Trans } from 'app/core/internationalization';
import { AccessControlAction, StoreState } from 'app/types';
import { selectTotal } from '../invites/state/selectors';
@@ -66,7 +67,10 @@ export const UsersActionBarUnconnected = ({
{pendingInvitesCount > 0 && (
@@ -74,7 +78,11 @@ export const UsersActionBarUnconnected = ({
)}
- {showInviteButton && Invite }
+ {showInviteButton && (
+
+ Invite
+
+ )}
{externalUserMngLinkUrl && (
{
+ private connectorLabel = t('variables.ad-hoc-filter.label-and', 'AND');
onChange = (index: number, prop: string) => (key: SelectableValue) => {
const { filters } = this.props;
const { value } = key;
@@ -55,7 +57,7 @@ export class AdHocFilter extends PureComponent {
{!disabled && (
0 ? : null}
+ appendBefore={filters.length > 0 ? : null}
onCompleted={this.appendFilterToVariable}
allFilters={this.getAllFilters()}
/>
@@ -79,7 +81,7 @@ export class AdHocFilter extends PureComponent {
return filters.reduce((segments: ReactNode[], filter, index) => {
if (segments.length > 0) {
- segments.push( );
+ segments.push( );
}
segments.push(this.renderFilterSegments(filter, index, disabled));
return segments;
diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts
index 4d3a5ca8e94..2a0b42b30ff 100644
--- a/public/app/features/variables/datasource/actions.test.ts
+++ b/public/app/features/variables/datasource/actions.test.ts
@@ -1,5 +1,5 @@
import { DataSourceInstanceSettings } from '@grafana/data';
-import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getMockPlugin } from '@grafana/data/test';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { variableAdapters } from '../adapters';
diff --git a/public/app/features/variables/datasource/reducer.test.ts b/public/app/features/variables/datasource/reducer.test.ts
index 2acb6e96346..6a3abaa27cb 100644
--- a/public/app/features/variables/datasource/reducer.test.ts
+++ b/public/app/features/variables/datasource/reducer.test.ts
@@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash';
import { DataSourceInstanceSettings, DataSourceVariableModel } from '@grafana/data';
-import { getMockPlugins } from '@grafana/data/test/__mocks__/pluginMocks';
+import { getMockPlugins } from '@grafana/data/test';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { getDataSourceInstanceSetting } from '../shared/testing/helpers';
diff --git a/public/app/features/variables/editor/ConfirmDeleteModal.tsx b/public/app/features/variables/editor/ConfirmDeleteModal.tsx
index 8b40c3d4adc..e95b5951cd9 100644
--- a/public/app/features/variables/editor/ConfirmDeleteModal.tsx
+++ b/public/app/features/variables/editor/ConfirmDeleteModal.tsx
@@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import { ConfirmModal } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
interface Props {
varName: string;
@@ -12,7 +13,7 @@ interface Props {
export function ConfirmDeleteModal({ varName, isOpen = false, onConfirm, onDismiss }: Props) {
return (
return (
<>
-