Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions ui/apps/pmm/src/api/alert-rule-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
RulerRuleGroup,
RulerRulesConfig,
} from 'types/alert-rule-groups.types';
import { grafanaApi } from './api';

// Mirrors Grafana's useFetchGroupsForFolder: the ruler namespace is the folder
// UID, and the response is keyed by namespace -> groups.
export const getFolderRuleGroups = async (
folderUid: string
): Promise<RulerRuleGroup[]> => {
const res = await grafanaApi.get<RulerRulesConfig>(
`/ruler/grafana/api/v1/rules/${encodeURIComponent(folderUid)}`,
{ params: { subtype: 'cortex' } }
);
return Object.values(res.data ?? {}).flat();
};
53 changes: 53 additions & 0 deletions ui/apps/pmm/src/api/alert-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
CreateRulePayload,
CreateTemplatePayload,
DeleteTemplatePayload,
ListTemplatesParams,
ListTemplatesResponse,
UpdateTemplatePayload,
} from 'types/alert-templates.types';
import { EmptyResponse } from 'types/util.types';
import { api } from './api';

export const listTemplates = async (
params?: ListTemplatesParams
): Promise<ListTemplatesResponse> => {
const res = await api.get<ListTemplatesResponse>('/alerting/templates', {
params,
});
return res.data;
};

export const createTemplate = async (
payload: CreateTemplatePayload
): Promise<EmptyResponse> => {
const res = await api.post<EmptyResponse>('/alerting/templates', payload);
return res.data;
};

export const updateTemplate = async ({
name,
yaml,
}: UpdateTemplatePayload): Promise<EmptyResponse> => {
const res = await api.put<EmptyResponse>(
`/alerting/templates/${encodeURIComponent(name)}`,
{ name, yaml }
);
return res.data;
};

export const deleteTemplate = async ({
name,
}: DeleteTemplatePayload): Promise<EmptyResponse> => {
const res = await api.delete<EmptyResponse>(
`/alerting/templates/${encodeURIComponent(name)}`
);
return res.data;
};

export const createRuleFromTemplate = async (
payload: CreateRulePayload
): Promise<EmptyResponse> => {
const res = await api.post<EmptyResponse>('/alerting/rules', payload);
return res.data;
};
11 changes: 10 additions & 1 deletion ui/apps/pmm/src/api/folders.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { GetFoldersResponse } from 'types/folders.types';
import {
CreateFolderPayload,
DashboardFolder,
GetFoldersResponse,
} from 'types/folders.types';
import { grafanaApi } from './api';

export const getDashboardFolders = async () => {
const res = await grafanaApi.get<GetFoldersResponse>('/folders');
return res.data;
};

export const createDashboardFolder = async (payload: CreateFolderPayload) => {
const res = await grafanaApi.post<DashboardFolder>('/folders', payload);
return res.data;
};
4 changes: 2 additions & 2 deletions ui/apps/pmm/src/contexts/navigation/navigation.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,8 +473,8 @@ export const NAV_EXPLORE: NavItem = {
export const NAV_ALERTS_TEMPLATES: NavItem = {
id: 'alerts-templates',
text: 'Alert templates',
url: `${PMM_NEW_NAV_GRAFANA_PATH}/alerting/alert-rule-templates`,
matches: [`${PMM_NEW_NAV_GRAFANA_PATH}/alerting/new-from-template/*`],
url: `${PMM_NEW_NAV_PATH}/alerting/alert-rule-templates`,
matches: [`${PMM_NEW_NAV_PATH}/alerting/new-from-template`],
};

export const NAV_ALERTS_FIRED: NavItem = {
Expand Down
110 changes: 110 additions & 0 deletions ui/apps/pmm/src/hooks/api/useAlertTemplates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query';
import {
createRuleFromTemplate,
createTemplate,
deleteTemplate,
listTemplates,
updateTemplate,
} from 'api/alert-templates';
import {
CreateRulePayload,
CreateTemplatePayload,
DeleteTemplatePayload,
ListTemplatesParams,
ListTemplatesResponse,
UpdateTemplatePayload,
} from 'types/alert-templates.types';
import { EmptyResponse } from 'types/util.types';

const KEYS = {
LIST_TEMPLATES: 'alert-templates:list',
CREATE_TEMPLATE: 'alert-templates:create',
UPDATE_TEMPLATE: 'alert-templates:update',
DELETE_TEMPLATE: 'alert-templates:delete',
CREATE_RULE: 'alert-templates:create-rule',
};

export const useAlertTemplates = (
params?: ListTemplatesParams,
options?: Partial<UseQueryOptions<ListTemplatesResponse>>
) =>
useQuery({
queryKey: [KEYS.LIST_TEMPLATES, params],
queryFn: () => listTemplates(params),
...options,
});

export const useCreateTemplate = (
options?: Partial<
UseMutationOptions<EmptyResponse, Error, CreateTemplatePayload>
>
) => {
const queryClient = useQueryClient();

return useMutation({
mutationKey: [KEYS.CREATE_TEMPLATE],
mutationFn: createTemplate,
...options,
onSuccess: async (data, variables, onMutate, context) => {
await options?.onSuccess?.(data, variables, onMutate, context);
await queryClient.invalidateQueries({
queryKey: [KEYS.LIST_TEMPLATES],
});
},
});
};

export const useUpdateTemplate = (
options?: Partial<
UseMutationOptions<EmptyResponse, Error, UpdateTemplatePayload>
>
) => {
const queryClient = useQueryClient();

return useMutation({
mutationKey: [KEYS.UPDATE_TEMPLATE],
mutationFn: updateTemplate,
...options,
onSuccess: async (data, variables, onMutate, context) => {
await options?.onSuccess?.(data, variables, onMutate, context);
await queryClient.invalidateQueries({
queryKey: [KEYS.LIST_TEMPLATES],
});
},
});
};

export const useDeleteTemplate = (
options?: Partial<
UseMutationOptions<EmptyResponse, Error, DeleteTemplatePayload>
>
) => {
const queryClient = useQueryClient();

return useMutation({
mutationKey: [KEYS.DELETE_TEMPLATE],
mutationFn: deleteTemplate,
...options,
onSuccess: async (data, variables, onMutate, context) => {
await options?.onSuccess?.(data, variables, onMutate, context);
await queryClient.invalidateQueries({
queryKey: [KEYS.LIST_TEMPLATES],
});
},
});
};

export const useCreateRuleFromTemplate = (
options?: Partial<UseMutationOptions<EmptyResponse, Error, CreateRulePayload>>
) =>
useMutation({
mutationKey: [KEYS.CREATE_RULE],
mutationFn: createRuleFromTemplate,
...options,
});
14 changes: 14 additions & 0 deletions ui/apps/pmm/src/hooks/api/useFolderRuleGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { getFolderRuleGroups } from 'api/alert-rule-groups';
import { RulerRuleGroup } from 'types/alert-rule-groups.types';

export const useFolderRuleGroups = (
folderUid: string,
options?: Partial<UseQueryOptions<RulerRuleGroup[]>>
) =>
useQuery({
queryKey: ['alert-rule-groups', folderUid],
queryFn: () => getFolderRuleGroups(folderUid),
enabled: !!folderUid,
...options,
});
34 changes: 31 additions & 3 deletions ui/apps/pmm/src/hooks/api/useFolders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { getDashboardFolders } from 'api/folders';
import { GetFoldersResponse } from 'types/folders.types';
import {
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query';
import { createDashboardFolder, getDashboardFolders } from 'api/folders';
import {
CreateFolderPayload,
DashboardFolder,
GetFoldersResponse,
} from 'types/folders.types';

export const useFolders = (
options?: Partial<UseQueryOptions<GetFoldersResponse>>
Expand All @@ -10,3 +20,21 @@ export const useFolders = (
queryFn: async () => getDashboardFolders(),
...options,
});

export const useCreateFolder = (
options?: Partial<
UseMutationOptions<DashboardFolder, Error, CreateFolderPayload>
>
) => {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ['folders:create'],
mutationFn: createDashboardFolder,
...options,
onSuccess: async (data, variables, onMutate, context) => {
await options?.onSuccess?.(data, variables, onMutate, context);
await queryClient.invalidateQueries({ queryKey: ['folders'] });
},
});
};
2 changes: 2 additions & 0 deletions ui/apps/pmm/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const PMM_HOME_URL = `${GRAFANA_SUB_PATH}/d/pmm-home`;
export const PMM_LOGIN_URL = `${GRAFANA_SUB_PATH}/login`;
export const PMM_SETTINGS_URL = `${PMM_BASE_PATH}/settings`;
export const PMM_NEW_NAV_UPDATES_PATH = `${PMM_NEW_NAV_PATH}/updates`;
export const PMM_ALERTING_TEMPLATES_PATH = `${PMM_NEW_NAV_PATH}/alerting/alert-rule-templates`;
export const PMM_ALERTING_NEW_FROM_TEMPLATE_PATH = `${PMM_NEW_NAV_PATH}/alerting/new-from-template`;
export const PMM_SUPPORT_URL = 'https://per.co.na/pmm_documentation';
export const PMM_DOCS_UPDATES_URL = 'https://per.co.na/pmm-upgrade';
export const PMM_DOCS_UPDATE_CLIENT_URL = 'https://per.co.na/pmm-upgrade-agent';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FilterType, Severity } from 'types/alert-templates.types';
import { Messages } from './CreateAlertFromTemplate.messages';

export const DEFAULT_DURATION_SECONDS = 60;

// Evaluation interval mirrors Grafana: a Prometheus duration string with
// quick-pick presets, validated as >= 10s and a multiple of 10s.
export const DEFAULT_INTERVAL = '1m';
export const MIN_INTERVAL_SECONDS = 10;
export const INTERVAL_STEP_SECONDS = 10;
export const EVALUATION_INTERVAL_OPTIONS = [
'10s',
'30s',
'1m',
'5m',
'10m',
'15m',
'30m',
'1h',
];

export const SEVERITY_OPTIONS: { value: Severity; label: string }[] = [
{ value: Severity.EMERGENCY, label: 'Emergency' },
{ value: Severity.ALERT, label: 'Alert' },
{ value: Severity.CRITICAL, label: 'Critical' },
{ value: Severity.ERROR, label: 'Error' },
{ value: Severity.WARNING, label: 'Warning' },
{ value: Severity.NOTICE, label: 'Notice' },
{ value: Severity.INFO, label: 'Info' },
{ value: Severity.DEBUG, label: 'Debug' },
];

export const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
{ value: FilterType.MATCH, label: Messages.filterTypes.match },
{ value: FilterType.MISMATCH, label: Messages.filterTypes.mismatch },
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export const Messages = {
title: 'Create alert rule from template',
sections: {
template: 'Template',
params: 'Parameters',
details: 'Rule details',
location: 'Folder & evaluation',
filters: 'Filters',
advanced: 'Advanced (read-only)',
},
fields: {
template: 'Template',
name: 'Rule name',
severity: 'Severity',
duration: 'Duration (seconds)',
folder: 'Folder',
newFolder: 'New folder',
group: 'Evaluation group',
interval: 'Evaluation interval',
expr: 'Expression',
},
newFolderModal: {
title: 'New folder',
nameLabel: 'Folder name',
namePlaceholder: 'Enter a name',
nameRequired: 'Required.',
cancel: 'Cancel',
create: 'Create',
error: 'Failed to create folder',
},
intervalHelper:
'How often the rule is evaluated, e.g. 1m, 30s (min 10s, multiple of 10s).',
group: {
label: 'Evaluation group and interval',
labelNoFolder:
'Select a folder before setting evaluation group and interval',
placeholder: 'Select an evaluation group...',
loading: 'Loading groups...',
or: 'or',
newGroup: 'New evaluation group',
evaluatedEvery: (interval: string) =>
`All rules in the selected group are evaluated every ${interval}.`,
},
newGroupModal: {
title: 'New evaluation group',
subtitle: 'Create a new evaluation group to use for this alert rule.',
nameLabel: 'Evaluation group name',
nameDescription:
'A group evaluates all its rules over the same evaluation interval.',
namePlaceholder: 'Enter a name',
nameRequired: 'Required.',
intervalLabel: 'Evaluation interval',
intervalDescription: 'How often all rules in the group are evaluated.',
cancel: 'Cancel',
create: 'Create',
},
filters: {
add: 'Add filter',
label: 'Label',
type: 'Operator',
regexp: 'Value / regexp',
remove: 'Remove filter',
},
filterTypes: {
match: 'Matches (=~)',
mismatch: 'Does not match (!~)',
},
cancel: 'Cancel',
submit: 'Create alert rule',
success: 'Alert rule created successfully',
error: 'Failed to create alert rule',
loadError: 'Failed to load alert templates',
validation: {
required: 'This field is required',
min: (min: number) => `Value must be at least ${min}`,
max: (max: number) => `Value must be at most ${max}`,
minDuration: 'Duration must be at least 1 second',
invalidInterval: 'Must be a valid duration, e.g. 1m or 30s',
intervalMin: 'Cannot be less than 10 seconds',
intervalMultiple: 'Must be a multiple of 10 seconds',
},
paramDescription: (summary: string, unit: string, range: string) =>
[summary, unit && `(${unit})`, range].filter(Boolean).join(' '),
};
Loading
Loading