mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/main' into drclau/unistor/replace-authenticators-3
This commit is contained in:
commit
8686c46be5
@ -72,7 +72,10 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
AccessControlAction.AlertingSilenceRead,
|
AccessControlAction.AlertingSilenceRead,
|
||||||
]),
|
]),
|
||||||
component: importAlertingComponent(
|
component: importAlertingComponent(
|
||||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "SilencesTablePage" */ 'app/features/alerting/unified/components/silences/SilencesTable'
|
||||||
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -84,13 +87,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
AccessControlAction.AlertingSilenceUpdate,
|
AccessControlAction.AlertingSilenceUpdate,
|
||||||
]),
|
]),
|
||||||
component: importAlertingComponent(
|
component: importAlertingComponent(
|
||||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
() => import(/* webpackChunkName: "NewSilencePage" */ 'app/features/alerting/unified/NewSilencePage')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/alerting/silence/:id/edit',
|
path: '/alerting/silence/:id/edit',
|
||||||
component: importAlertingComponent(
|
component: importAlertingComponent(
|
||||||
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "ExistingSilenceEditorPage" */ 'app/features/alerting/unified/components/silences/SilencesEditor'
|
||||||
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
51
public/app/features/alerting/unified/NewSilencePage.tsx
Normal file
51
public/app/features/alerting/unified/NewSilencePage.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { withErrorBoundary } from '@grafana/ui';
|
||||||
|
import {
|
||||||
|
defaultsFromQuery,
|
||||||
|
getDefaultSilenceFormValues,
|
||||||
|
} from 'app/features/alerting/unified/components/silences/utils';
|
||||||
|
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
|
||||||
|
import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers';
|
||||||
|
|
||||||
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
|
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
|
||||||
|
import { SilencesEditor } from './components/silences/SilencesEditor';
|
||||||
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
|
|
||||||
|
const SilencesEditorComponent = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const queryParams = new URLSearchParams(location.search);
|
||||||
|
const { selectedAlertmanager = '' } = useAlertmanager();
|
||||||
|
const potentialAlertRuleMatcher = parseQueryParamMatchers(queryParams.getAll('matcher')).find(
|
||||||
|
(m) => m.name === MATCHER_ALERT_RULE_UID
|
||||||
|
);
|
||||||
|
|
||||||
|
const potentialRuleUid = potentialAlertRuleMatcher?.value;
|
||||||
|
const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} />
|
||||||
|
<SilencesEditor
|
||||||
|
formValues={formValues}
|
||||||
|
alertManagerSourceName={selectedAlertmanager}
|
||||||
|
ruleUid={potentialRuleUid}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function NewSilencePage() {
|
||||||
|
const pageNav = {
|
||||||
|
id: 'silence-new',
|
||||||
|
text: 'Silence alert rule',
|
||||||
|
subTitle: 'Configure silences to stop notifications from a particular alert rule',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance">
|
||||||
|
<SilencesEditorComponent />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withErrorBoundary(NewSilencePage, { style: 'page' });
|
@ -1,4 +1,4 @@
|
|||||||
import { useParams } from 'react-router-dom-v5-compat';
|
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||||
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
|
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
|
||||||
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
|
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
@ -17,7 +17,9 @@ import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/cons
|
|||||||
import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
import Silences from './Silences';
|
import NewSilencePage from './NewSilencePage';
|
||||||
|
import ExistingSilenceEditorPage from './components/silences/SilencesEditor';
|
||||||
|
import SilencesTablePage from './components/silences/SilencesTable';
|
||||||
import {
|
import {
|
||||||
MOCK_SILENCE_ID_EXISTING,
|
MOCK_SILENCE_ID_EXISTING,
|
||||||
MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID,
|
MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID,
|
||||||
@ -30,21 +32,21 @@ import { grafanaRulerRule } from './mocks/grafanaRulerApi';
|
|||||||
import { setupDataSources } from './testSetup/datasources';
|
import { setupDataSources } from './testSetup/datasources';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
|
|
||||||
jest.mock('app/core/services/context_srv');
|
|
||||||
|
|
||||||
jest.mock('react-router-dom-v5-compat', () => ({
|
|
||||||
...jest.requireActual('react-router-dom-v5-compat'),
|
|
||||||
useParams: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const TEST_TIMEOUT = 60000;
|
const TEST_TIMEOUT = 60000;
|
||||||
|
|
||||||
const renderSilences = (location = '/alerting/silences/') => {
|
const renderSilences = (location = '/alerting/silences/') => {
|
||||||
return render(<Silences />, {
|
return render(
|
||||||
historyOptions: {
|
<Routes>
|
||||||
initialEntries: [location],
|
<Route path="/alerting/silences" element={<SilencesTablePage />} />
|
||||||
},
|
<Route path="/alerting/silence/new" element={<NewSilencePage />} />
|
||||||
});
|
<Route path="/alerting/silence/:id/edit" element={<ExistingSilenceEditorPage />} />
|
||||||
|
</Routes>,
|
||||||
|
{
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: [location],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataSources = {
|
const dataSources = {
|
||||||
@ -124,8 +126,7 @@ describe('Silences', () => {
|
|||||||
it(
|
it(
|
||||||
'loads and shows silences',
|
'loads and shows silences',
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
const { user } = renderSilences();
|
||||||
renderSilences();
|
|
||||||
|
|
||||||
expect(await ui.notExpiredTable.find()).toBeInTheDocument();
|
expect(await ui.notExpiredTable.find()).toBeInTheDocument();
|
||||||
|
|
||||||
@ -174,8 +175,7 @@ describe('Silences', () => {
|
|||||||
it(
|
it(
|
||||||
'filters silences by matchers',
|
'filters silences by matchers',
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
const { user } = renderSilences();
|
||||||
renderSilences();
|
|
||||||
|
|
||||||
const queryBar = await ui.queryBar.find();
|
const queryBar = await ui.queryBar.find();
|
||||||
await user.type(queryBar, 'foo=bar');
|
await user.type(queryBar, 'foo=bar');
|
||||||
@ -260,8 +260,7 @@ describe('Silence create/edit', () => {
|
|||||||
it(
|
it(
|
||||||
'creates a new silence',
|
'creates a new silence',
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
const { user } = renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
|
||||||
renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
|
|
||||||
expect(await ui.editor.durationField.find()).toBeInTheDocument();
|
expect(await ui.editor.durationField.find()).toBeInTheDocument();
|
||||||
|
|
||||||
const postRequest = waitForServerRequest(silenceCreateHandler());
|
const postRequest = waitForServerRequest(silenceCreateHandler());
|
||||||
@ -320,20 +319,17 @@ describe('Silence create/edit', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error when existing silence cannot be found', async () => {
|
it('shows an error when existing silence cannot be found', async () => {
|
||||||
(useParams as jest.Mock).mockReturnValue({ id: 'foo-bar' });
|
|
||||||
renderSilences('/alerting/silence/foo-bar/edit');
|
renderSilences('/alerting/silence/foo-bar/edit');
|
||||||
|
|
||||||
expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument();
|
expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error when user cannot edit/recreate silence', async () => {
|
it('shows an error when user cannot edit/recreate silence', async () => {
|
||||||
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_LACKING_PERMISSIONS });
|
|
||||||
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_LACKING_PERMISSIONS}/edit`);
|
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_LACKING_PERMISSIONS}/edit`);
|
||||||
expect(await ui.noPermissionToEdit.find()).toBeInTheDocument();
|
expect(await ui.noPermissionToEdit.find()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('populates form with existing silence information', async () => {
|
it('populates form with existing silence information', async () => {
|
||||||
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING });
|
|
||||||
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`);
|
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`);
|
||||||
|
|
||||||
// Await the first value to be populated, after which we can expect that all of the other
|
// Await the first value to be populated, after which we can expect that all of the other
|
||||||
@ -344,7 +340,6 @@ describe('Silence create/edit', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('populates form with existing silence information that has __alert_rule_uid__', async () => {
|
it('populates form with existing silence information that has __alert_rule_uid__', async () => {
|
||||||
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID });
|
|
||||||
mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule);
|
mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule);
|
||||||
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}/edit`);
|
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}/edit`);
|
||||||
expect(await screen.findByLabelText(/alert rule/i)).toHaveValue(grafanaRulerRule.grafana_alert.title);
|
expect(await screen.findByLabelText(/alert rule/i)).toHaveValue(grafanaRulerRule.grafana_alert.title);
|
||||||
@ -358,11 +353,9 @@ describe('Silence create/edit', () => {
|
|||||||
it(
|
it(
|
||||||
'silences page should contain alertmanager parameter after creating a silence',
|
'silences page should contain alertmanager parameter after creating a silence',
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
const postRequest = waitForServerRequest(silenceCreateHandler());
|
const postRequest = waitForServerRequest(silenceCreateHandler());
|
||||||
|
|
||||||
renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
|
const { user } = renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
|
||||||
await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull());
|
await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull());
|
||||||
|
|
||||||
await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar');
|
await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar');
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
import { useLocation } from 'react-router-dom-v5-compat';
|
|
||||||
|
|
||||||
import { withErrorBoundary } from '@grafana/ui';
|
|
||||||
import {
|
|
||||||
defaultsFromQuery,
|
|
||||||
getDefaultSilenceFormValues,
|
|
||||||
} from 'app/features/alerting/unified/components/silences/utils';
|
|
||||||
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
|
|
||||||
import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers';
|
|
||||||
|
|
||||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
|
||||||
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
|
|
||||||
import ExistingSilenceEditor, { SilencesEditor } from './components/silences/SilencesEditor';
|
|
||||||
import SilencesTable from './components/silences/SilencesTable';
|
|
||||||
import { useSilenceNavData } from './hooks/useSilenceNavData';
|
|
||||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
|
||||||
|
|
||||||
const Silences = () => {
|
|
||||||
const { selectedAlertmanager } = useAlertmanager();
|
|
||||||
|
|
||||||
if (!selectedAlertmanager) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} />
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/alerting/silences">
|
|
||||||
<SilencesTable alertManagerSourceName={selectedAlertmanager} />
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/alerting/silence/new">
|
|
||||||
<SilencesEditorComponent selectedAlertmanager={selectedAlertmanager} />
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/alerting/silence/:id/edit">
|
|
||||||
<ExistingSilenceEditor alertManagerSourceName={selectedAlertmanager} />
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function SilencesPage() {
|
|
||||||
const pageNav = useSilenceNavData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance">
|
|
||||||
<Silences />
|
|
||||||
</AlertmanagerPageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withErrorBoundary(SilencesPage, { style: 'page' });
|
|
||||||
|
|
||||||
type SilencesEditorComponentProps = {
|
|
||||||
selectedAlertmanager: string;
|
|
||||||
};
|
|
||||||
const SilencesEditorComponent = ({ selectedAlertmanager }: SilencesEditorComponentProps) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const queryParams = new URLSearchParams(location.search);
|
|
||||||
|
|
||||||
const potentialAlertRuleMatcher = parseQueryParamMatchers(queryParams.getAll('matcher')).find(
|
|
||||||
(m) => m.name === MATCHER_ALERT_RULE_UID
|
|
||||||
);
|
|
||||||
|
|
||||||
const potentialRuleUid = potentialAlertRuleMatcher?.value;
|
|
||||||
|
|
||||||
const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SilencesEditor formValues={formValues} alertManagerSourceName={selectedAlertmanager} ruleUid={potentialRuleUid} />
|
|
||||||
);
|
|
||||||
};
|
|
@ -25,6 +25,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextArea,
|
TextArea,
|
||||||
useStyles2,
|
useStyles2,
|
||||||
|
withErrorBoundary,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi';
|
import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi';
|
||||||
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
|
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
|
||||||
@ -32,26 +33,26 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from 'app/features/ale
|
|||||||
import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||||
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { SilenceFormFields } from '../../types/silence-form';
|
import { SilenceFormFields } from '../../types/silence-form';
|
||||||
import { matcherFieldToMatcher } from '../../utils/alertmanager';
|
import { matcherFieldToMatcher } from '../../utils/alertmanager';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { makeAMLink } from '../../utils/misc';
|
||||||
|
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
|
||||||
|
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
|
||||||
|
|
||||||
import MatchersField from './MatchersField';
|
import MatchersField from './MatchersField';
|
||||||
import { SilencePeriod } from './SilencePeriod';
|
import { SilencePeriod } from './SilencePeriod';
|
||||||
import { SilencedInstancesPreview } from './SilencedInstancesPreview';
|
import { SilencedInstancesPreview } from './SilencedInstancesPreview';
|
||||||
import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils';
|
import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils';
|
||||||
|
|
||||||
interface Props {
|
|
||||||
alertManagerSourceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Silences editor for editing an existing silence.
|
* Silences editor for editing an existing silence.
|
||||||
*
|
*
|
||||||
* Fetches silence details from API, based on `silenceId`
|
* Fetches silence details from API, based on `silenceId`
|
||||||
*/
|
*/
|
||||||
const ExistingSilenceEditor = ({ alertManagerSourceName }: Props) => {
|
const ExistingSilenceEditor = () => {
|
||||||
const { id: silenceId = '' } = useParams();
|
const { id: silenceId = '' } = useParams();
|
||||||
|
const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager();
|
||||||
const {
|
const {
|
||||||
data: silence,
|
data: silence,
|
||||||
isLoading: getSilenceIsLoading,
|
isLoading: getSilenceIsLoading,
|
||||||
@ -91,7 +92,10 @@ const ExistingSilenceEditor = ({ alertManagerSourceName }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} />
|
<>
|
||||||
|
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={alertManagerSourceName} />
|
||||||
|
<SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -279,4 +283,16 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ExistingSilenceEditor;
|
function ExistingSilenceEditorPage() {
|
||||||
|
const pageNav = {
|
||||||
|
id: 'silence-edit',
|
||||||
|
text: 'Edit silence',
|
||||||
|
subTitle: 'Recreate existing silence to stop notifications from a particular alert rule',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance">
|
||||||
|
<ExistingSilenceEditor />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withErrorBoundary(ExistingSilenceEditorPage, { style: 'page' });
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
LoadingPlaceholder,
|
LoadingPlaceholder,
|
||||||
Stack,
|
Stack,
|
||||||
useStyles2,
|
useStyles2,
|
||||||
|
withErrorBoundary,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
import { Trans } from 'app/core/internationalization';
|
import { Trans } from 'app/core/internationalization';
|
||||||
@ -23,10 +24,13 @@ import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource
|
|||||||
|
|
||||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||||
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers';
|
import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers';
|
||||||
import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc';
|
import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc';
|
||||||
|
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
|
||||||
import { Authorize } from '../Authorize';
|
import { Authorize } from '../Authorize';
|
||||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||||
|
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
|
||||||
|
|
||||||
import { Matchers } from './Matchers';
|
import { Matchers } from './Matchers';
|
||||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||||
@ -40,13 +44,11 @@ export interface SilenceTableItem extends Silence {
|
|||||||
|
|
||||||
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
|
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
|
||||||
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
|
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
|
||||||
interface Props {
|
|
||||||
alertManagerSourceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_QUERY_OPTIONS = { pollingInterval: SILENCES_POLL_INTERVAL_MS, refetchOnFocus: true };
|
const API_QUERY_OPTIONS = { pollingInterval: SILENCES_POLL_INTERVAL_MS, refetchOnFocus: true };
|
||||||
|
|
||||||
const SilencesTable = ({ alertManagerSourceName }: Props) => {
|
const SilencesTable = () => {
|
||||||
|
const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager();
|
||||||
const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility(
|
const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility(
|
||||||
AlertmanagerAction.PreviewSilencedInstances
|
AlertmanagerAction.PreviewSilencedInstances
|
||||||
);
|
);
|
||||||
@ -135,6 +137,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="silences-table">
|
<div data-testid="silences-table">
|
||||||
|
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={alertManagerSourceName} />
|
||||||
{!!silences.length && (
|
{!!silences.length && (
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
<SilencesFilter />
|
<SilencesFilter />
|
||||||
@ -382,4 +385,12 @@ function useColumns(alertManagerSourceName: string) {
|
|||||||
return columns;
|
return columns;
|
||||||
}, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]);
|
}, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]);
|
||||||
}
|
}
|
||||||
export default SilencesTable;
|
|
||||||
|
function SilencesTablePage() {
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId="silences" accessType="instance">
|
||||||
|
<SilencesTable />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withErrorBoundary(SilencesTablePage, { style: 'page' });
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import { useMatch } from 'react-router-dom-v5-compat';
|
|
||||||
|
|
||||||
import { useSilenceNavData } from './useSilenceNavData';
|
|
||||||
|
|
||||||
jest.mock('react-router-dom-v5-compat', () => ({
|
|
||||||
...jest.requireActual('react-router-dom-v5-compat'),
|
|
||||||
useMatch: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const setup = () => {
|
|
||||||
let result: ReturnType<typeof useSilenceNavData>;
|
|
||||||
function TestComponent() {
|
|
||||||
result = useSilenceNavData();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(<TestComponent />);
|
|
||||||
|
|
||||||
return { result };
|
|
||||||
};
|
|
||||||
describe('useSilenceNavData', () => {
|
|
||||||
it('should return correct nav data when route is "/alerting/silence/new"', () => {
|
|
||||||
(useMatch as jest.Mock).mockImplementation((param) => param === '/alerting/silence/new');
|
|
||||||
const { result } = setup();
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
text: 'Silence alert rule',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct nav data when route is "/alerting/silence/:id/edit"', () => {
|
|
||||||
(useMatch as jest.Mock).mockImplementation((param) => param === '/alerting/silence/:id/edit');
|
|
||||||
const { result } = setup();
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
text: 'Edit silence',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,34 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useMatch } from 'react-router-dom-v5-compat';
|
|
||||||
|
|
||||||
import { NavModelItem } from '@grafana/data';
|
|
||||||
|
|
||||||
const defaultPageNav: Partial<NavModelItem> = {
|
|
||||||
icon: 'bell-slash',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSilenceNavData() {
|
|
||||||
const [pageNav, setPageNav] = useState<NavModelItem | undefined>();
|
|
||||||
const isNewPath = useMatch('/alerting/silence/new');
|
|
||||||
const isEditPath = useMatch('/alerting/silence/:id/edit');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isNewPath) {
|
|
||||||
setPageNav({
|
|
||||||
...defaultPageNav,
|
|
||||||
id: 'silence-new',
|
|
||||||
text: 'Silence alert rule',
|
|
||||||
subTitle: 'Configure silences to stop notifications from a particular alert rule',
|
|
||||||
});
|
|
||||||
} else if (isEditPath) {
|
|
||||||
setPageNav({
|
|
||||||
...defaultPageNav,
|
|
||||||
id: 'silence-edit',
|
|
||||||
text: 'Edit silence',
|
|
||||||
subTitle: 'Recreate existing silence to stop notifications from a particular alert rule',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isEditPath, isNewPath]);
|
|
||||||
|
|
||||||
return pageNav;
|
|
||||||
}
|
|
@ -22,7 +22,12 @@ import { ScopesInput } from './ScopesInput';
|
|||||||
import { ScopesTree } from './ScopesTree';
|
import { ScopesTree } from './ScopesTree';
|
||||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
|
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
|
||||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||||
import { getBasicScope, getScopeNamesFromSelectedScopes, getTreeScopesFromSelectedScopes } from './utils';
|
import {
|
||||||
|
getBasicScope,
|
||||||
|
getScopeNamesFromSelectedScopes,
|
||||||
|
getScopesAndTreeScopesWithPaths,
|
||||||
|
getTreeScopesFromSelectedScopes,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
export interface ScopesSelectorSceneState extends SceneObjectState {
|
export interface ScopesSelectorSceneState extends SceneObjectState {
|
||||||
dashboards: SceneObjectRef<ScopesDashboardsScene> | null;
|
dashboards: SceneObjectRef<ScopesDashboardsScene> | null;
|
||||||
@ -126,7 +131,14 @@ export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneStat
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe((childNodes) => {
|
.subscribe((childNodes) => {
|
||||||
const persistedNodes = this.state.treeScopes
|
const [scopes, treeScopes] = getScopesAndTreeScopesWithPaths(
|
||||||
|
this.state.scopes,
|
||||||
|
this.state.treeScopes,
|
||||||
|
path,
|
||||||
|
childNodes
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistedNodes = treeScopes
|
||||||
.map(({ path }) => path[path.length - 1])
|
.map(({ path }) => path[path.length - 1])
|
||||||
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
|
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
|
||||||
.reduce<NodesMap>((acc, nodeName) => {
|
.reduce<NodesMap>((acc, nodeName) => {
|
||||||
@ -140,7 +152,7 @@ export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneStat
|
|||||||
|
|
||||||
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
||||||
|
|
||||||
this.setState({ nodes });
|
this.setState({ nodes, scopes, treeScopes });
|
||||||
|
|
||||||
this.nodesFetchingSub?.unsubscribe();
|
this.nodesFetchingSub?.unsubscribe();
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Scope, ScopeDashboardBinding } from '@grafana/data';
|
import { Scope, ScopeDashboardBinding } from '@grafana/data';
|
||||||
|
|
||||||
import { SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types';
|
import { NodesMap, SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types';
|
||||||
|
|
||||||
export function getBasicScope(name: string): Scope {
|
export function getBasicScope(name: string): Scope {
|
||||||
return {
|
return {
|
||||||
@ -44,6 +44,59 @@ export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string
|
|||||||
return scopes.map(({ scope }) => scope.metadata.name);
|
return scopes.map(({ scope }) => scope.metadata.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
|
||||||
|
export 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<Record<string, string[]>>((acc, scopeName) => {
|
||||||
|
const possibleParent = childNodesArr.find((childNode) => childNode.isSelectable && childNode.linkId === scopeName);
|
||||||
|
|
||||||
|
if (possibleParent) {
|
||||||
|
acc[scopeName] = [...path, possibleParent.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...treeScope,
|
||||||
|
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [newSelectedScopes, newTreeScopes];
|
||||||
|
}
|
||||||
|
|
||||||
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap {
|
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap {
|
||||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>(
|
return dashboards.reduce<SuggestedDashboardsFoldersMap>(
|
||||||
(acc, dashboard) => {
|
(acc, dashboard) => {
|
||||||
|
@ -36,6 +36,8 @@ import {
|
|||||||
expectResultCloudOpsSelected,
|
expectResultCloudOpsSelected,
|
||||||
expectScopesHeadline,
|
expectScopesHeadline,
|
||||||
expectScopesSelectorValue,
|
expectScopesSelectorValue,
|
||||||
|
expectSelectedScopePath,
|
||||||
|
expectTreeScopePath,
|
||||||
} from './utils/assertions';
|
} from './utils/assertions';
|
||||||
import { fetchNodesSpy, fetchScopeSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
import { fetchNodesSpy, fetchScopeSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||||
import { renderDashboard, resetScenes } from './utils/render';
|
import { renderDashboard, resetScenes } from './utils/render';
|
||||||
@ -244,4 +246,28 @@ describe('Tree', () => {
|
|||||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||||
expectScopesHeadline('No results found for your query');
|
expectScopesHeadline('No results found for your query');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Updates the paths for scopes without paths on nodes fetching', async () => {
|
||||||
|
const selectedScopeName = 'grafana';
|
||||||
|
const unselectedScopeName = 'mimir';
|
||||||
|
const selectedScopeNameFromOtherGroup = 'dev';
|
||||||
|
|
||||||
|
await updateScopes([selectedScopeName, selectedScopeNameFromOtherGroup]);
|
||||||
|
expectSelectedScopePath(selectedScopeName, []);
|
||||||
|
expectTreeScopePath(selectedScopeName, []);
|
||||||
|
expectSelectedScopePath(unselectedScopeName, undefined);
|
||||||
|
expectTreeScopePath(unselectedScopeName, undefined);
|
||||||
|
expectSelectedScopePath(selectedScopeNameFromOtherGroup, []);
|
||||||
|
expectTreeScopePath(selectedScopeNameFromOtherGroup, []);
|
||||||
|
|
||||||
|
await openSelector();
|
||||||
|
await expandResultApplications();
|
||||||
|
const expectedPath = ['', 'applications', 'applications-grafana'];
|
||||||
|
expectSelectedScopePath(selectedScopeName, expectedPath);
|
||||||
|
expectTreeScopePath(selectedScopeName, expectedPath);
|
||||||
|
expectSelectedScopePath(unselectedScopeName, undefined);
|
||||||
|
expectTreeScopePath(unselectedScopeName, undefined);
|
||||||
|
expectSelectedScopePath(selectedScopeNameFromOtherGroup, []);
|
||||||
|
expectTreeScopePath(selectedScopeNameFromOtherGroup, []);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -12,8 +12,10 @@ import {
|
|||||||
getResultApplicationsMimirSelect,
|
getResultApplicationsMimirSelect,
|
||||||
getResultCloudDevRadio,
|
getResultCloudDevRadio,
|
||||||
getResultCloudOpsRadio,
|
getResultCloudOpsRadio,
|
||||||
|
getSelectedScope,
|
||||||
getSelectorInput,
|
getSelectorInput,
|
||||||
getTreeHeadline,
|
getTreeHeadline,
|
||||||
|
getTreeScope,
|
||||||
queryAllDashboard,
|
queryAllDashboard,
|
||||||
queryDashboard,
|
queryDashboard,
|
||||||
queryDashboardFolderExpand,
|
queryDashboardFolderExpand,
|
||||||
@ -80,3 +82,8 @@ export const expectOldDashboardDTO = (scopes?: string[]) =>
|
|||||||
expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', scopes ? { scopes } : undefined);
|
expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', scopes ? { scopes } : undefined);
|
||||||
export const expectNewDashboardDTO = () =>
|
export const expectNewDashboardDTO = () =>
|
||||||
expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto');
|
expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto');
|
||||||
|
|
||||||
|
export const expectSelectedScopePath = (name: string, path: string[] | undefined) =>
|
||||||
|
expect(getSelectedScope(name)?.path).toEqual(path);
|
||||||
|
export const expectTreeScopePath = (name: string, path: string[] | undefined) =>
|
||||||
|
expect(getTreeScope(name)?.path).toEqual(path);
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { scopesSelectorScene } from '../../instance';
|
||||||
|
|
||||||
const selectors = {
|
const selectors = {
|
||||||
tree: {
|
tree: {
|
||||||
search: 'scopes-tree-search',
|
search: 'scopes-tree-search',
|
||||||
@ -82,3 +84,9 @@ export const getResultCloudDevRadio = () =>
|
|||||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev', 'result'));
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev', 'result'));
|
||||||
export const getResultCloudOpsRadio = () =>
|
export const getResultCloudOpsRadio = () =>
|
||||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
|
||||||
|
|
||||||
|
export const getListOfSelectedScopes = () => scopesSelectorScene?.state.scopes;
|
||||||
|
export const getListOfTreeScopes = () => scopesSelectorScene?.state.treeScopes;
|
||||||
|
export const getSelectedScope = (name: string) =>
|
||||||
|
getListOfSelectedScopes()?.find((selectedScope) => selectedScope.scope.metadata.name === name);
|
||||||
|
export const getTreeScope = (name: string) => getListOfTreeScopes()?.find((treeScope) => treeScope.scopeName === name);
|
||||||
|
@ -2218,7 +2218,9 @@
|
|||||||
"datasource-names": "",
|
"datasource-names": "",
|
||||||
"delete-query-button": "",
|
"delete-query-button": "",
|
||||||
"query-template-get-error": "",
|
"query-template-get-error": "",
|
||||||
"search": ""
|
"search": "",
|
||||||
|
"user-info-get-error": "",
|
||||||
|
"user-names": ""
|
||||||
},
|
},
|
||||||
"query-operation": {
|
"query-operation": {
|
||||||
"header": {
|
"header": {
|
||||||
|
@ -2218,7 +2218,9 @@
|
|||||||
"datasource-names": "",
|
"datasource-names": "",
|
||||||
"delete-query-button": "",
|
"delete-query-button": "",
|
||||||
"query-template-get-error": "",
|
"query-template-get-error": "",
|
||||||
"search": ""
|
"search": "",
|
||||||
|
"user-info-get-error": "",
|
||||||
|
"user-names": ""
|
||||||
},
|
},
|
||||||
"query-operation": {
|
"query-operation": {
|
||||||
"header": {
|
"header": {
|
||||||
|
@ -2218,7 +2218,9 @@
|
|||||||
"datasource-names": "",
|
"datasource-names": "",
|
||||||
"delete-query-button": "",
|
"delete-query-button": "",
|
||||||
"query-template-get-error": "",
|
"query-template-get-error": "",
|
||||||
"search": ""
|
"search": "",
|
||||||
|
"user-info-get-error": "",
|
||||||
|
"user-names": ""
|
||||||
},
|
},
|
||||||
"query-operation": {
|
"query-operation": {
|
||||||
"header": {
|
"header": {
|
||||||
|
@ -2218,7 +2218,9 @@
|
|||||||
"datasource-names": "",
|
"datasource-names": "",
|
||||||
"delete-query-button": "",
|
"delete-query-button": "",
|
||||||
"query-template-get-error": "",
|
"query-template-get-error": "",
|
||||||
"search": ""
|
"search": "",
|
||||||
|
"user-info-get-error": "",
|
||||||
|
"user-names": ""
|
||||||
},
|
},
|
||||||
"query-operation": {
|
"query-operation": {
|
||||||
"header": {
|
"header": {
|
||||||
|
@ -2208,7 +2208,9 @@
|
|||||||
"datasource-names": "",
|
"datasource-names": "",
|
||||||
"delete-query-button": "",
|
"delete-query-button": "",
|
||||||
"query-template-get-error": "",
|
"query-template-get-error": "",
|
||||||
"search": ""
|
"search": "",
|
||||||
|
"user-info-get-error": "",
|
||||||
|
"user-names": ""
|
||||||
},
|
},
|
||||||
"query-operation": {
|
"query-operation": {
|
||||||
"header": {
|
"header": {
|
||||||
|
Loading…
Reference in New Issue
Block a user