diff --git a/.github/workflows/linkspector.yml b/.github/workflows/linkspector.yml index f0603557a18..a20cf3ac1ef 100644 --- a/.github/workflows/linkspector.yml +++ b/.github/workflows/linkspector.yml @@ -24,6 +24,13 @@ jobs: with: ref: ${{ github.event.inputs.ref || github.ref }} + - name: Install Chrome for linkspector + id: setup-chrome + uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3 + + - name: Configure Chrome path for puppeteer + run: echo "PUPPETEER_EXECUTABLE_PATH=${{ steps.setup-chrome.outputs.chrome-path }}" >> "$GITHUB_ENV" + - name: Run linkspector uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1.5.1 with: diff --git a/dashboards/dashboards/Valkey/Valkey_ClusterDetails.json b/dashboards/dashboards/Valkey/Valkey_ClusterDetails.json index 045940f0133..c12a789a097 100644 --- a/dashboards/dashboards/Valkey/Valkey_ClusterDetails.json +++ b/dashboards/dashboards/Valkey/Valkey_ClusterDetails.json @@ -252,7 +252,7 @@ }, { "datasource": "Metrics", - "description": "Shows how many cluster communication messages are being sent and received over time. High message rates can indicate cluster instability or frequent topology changes.", + "description": "Cluster control-bus traffic as a rate (messages per second). High sustained rates can indicate instability or frequent topology changes. Raw counters are not shown.", "fieldConfig": { "defaults": { "color": { @@ -262,7 +262,7 @@ "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", - "axisLabel": "", + "axisLabel": "messages / s", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, @@ -303,7 +303,8 @@ "value": 80 } ] - } + }, + "unit": "ops" }, "overrides": [] }, @@ -338,7 +339,7 @@ { "datasource": "Metrics", "editorMode": "code", - "expr": "redis_cluster_messages_sent_total{service_name=~\"$service_name\"}", + "expr": "rate(redis_cluster_messages_sent_total{service_name=~\"$service_name\"}[$__rate_interval])", "legendFormat": "Sent - {{service_name}}", "range": true, "refId": "A" @@ -346,7 +347,7 @@ { "datasource": "Metrics", "editorMode": "code", - "expr": "redis_cluster_messages_received_total{service_name=~\"$service_name\"}", + "expr": "rate(redis_cluster_messages_received_total{service_name=~\"$service_name\"}[$__rate_interval])", "hide": false, "legendFormat": "Received - {{service_name}}", "range": true, diff --git a/documentation/docs/reference/pmm_components_and_versions.md b/documentation/docs/reference/pmm_components_and_versions.md index e3a75d00eb2..0ecb58c1c15 100644 --- a/documentation/docs/reference/pmm_components_and_versions.md +++ b/documentation/docs/reference/pmm_components_and_versions.md @@ -6,7 +6,7 @@ The following table lists all the PMM client/server components and their version |-----------------------------|----------|---------------|------------------| | Grafana | 11.6.13* | [Grafana documentation](https://grafana.com/docs/grafana/latest/)|[Github Grafana](https://github.com/percona-platform/grafana)| | VictoriaMetrics| v1.140.0 | [VictoriaMetrics documentation](https://docs.victoriametrics.com/)|[Github VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics) | -| Nginx | 1.20.1 | [Nginx documentation](http://nginx.org/en/docs/)|[Github Nginx](https://github.com/nginx/nginx) | +| Nginx | 1.26.3 | [Nginx documentation](http://nginx.org/en/docs/)|[Github Nginx](https://github.com/nginx/nginx) | | Percona Distribution for PostgreSQL | 14.5 | [Percona Distribution for PostgreSQL 14 documentation](https://www.percona.com/doc/postgresql/LATEST/index.html)| | | ClickHouse| 25.3.6.56 |[ClickHouse documentation](https://clickhouse.com/docs/en/)|[Github ClickHouse](https://github.com/ClickHouse/ClickHouse)| | PerconaToolkit | 3.5.2 | [Percona Toolkit documentation](https://www.percona.com/doc/percona-toolkit/3.0/index.html)|[Github Percona Toolkit](https://github.com/percona/percona-toolkit)| diff --git a/ui/apps/pmm/package.json b/ui/apps/pmm/package.json index f31704f721a..074f765215d 100644 --- a/ui/apps/pmm/package.json +++ b/ui/apps/pmm/package.json @@ -26,7 +26,7 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^7.5.0", - "@percona/percona-ui": "1.0.16", + "@percona/percona-ui": "1.0.18", "@pmm/shared": "*", "@reactour/tour": "^3.8.0", "@tanstack/react-query": "^5.45.1", diff --git a/ui/apps/pmm/src/api/alerting.ts b/ui/apps/pmm/src/api/alerting.ts new file mode 100644 index 00000000000..19b55a642bd --- /dev/null +++ b/ui/apps/pmm/src/api/alerting.ts @@ -0,0 +1,9 @@ +import { PrometheusAlertRulesResponse } from 'types/alerting.types'; +import { grafanaApi } from './api'; + +export const getPrometheusAlertRules = async () => { + const response = await grafanaApi.get( + '/prometheus/grafana/api/v1/rules' + ); + return response.data; +}; diff --git a/ui/apps/pmm/src/components/sidebar/Sidebar.tsx b/ui/apps/pmm/src/components/sidebar/Sidebar.tsx index 7a8acb7ddef..dce37aa6727 100644 --- a/ui/apps/pmm/src/components/sidebar/Sidebar.tsx +++ b/ui/apps/pmm/src/components/sidebar/Sidebar.tsx @@ -2,7 +2,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; import { useNavigation } from 'contexts/navigation'; import { NavigationHeading } from './nav-heading'; import { Drawer } from './drawer'; -import { NavItem } from './nav-item'; +import { SidebarNavItem } from './nav-item'; import List from '@mui/material/List'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material/styles'; @@ -69,7 +69,7 @@ export const Sidebar: FC = () => { ]} > {navTree.map((item) => ( - ({ @@ -16,71 +13,33 @@ export const getStyles = ( ? { mr: level > 0 ? 1 : 0, } - : { - py: 1.5, - }, + : {}, navItemRootCollapsible: { borderTopLeftRadius: 0, borderBottomLeftRadius: 0, }, - listItemButton: { - px: 2, - height: 48, - borderRadius: 50, - justifyContent: drawerOpen ? undefined : 'center', - - [`.${typographyClasses.root}`]: { - fontWeight: 600, - }, - - [`&, .${listItemIconClasses.root}`]: { - color: active ? theme.palette.primary.main : theme.palette.text.primary, - }, - }, - listItemButtonCollapsible: { - backgroundColor: active - ? theme.components?.MuiListItemButton?.styleOverrides?.selected - : 'initial', - }, listCollapsible: level === 0 ? { - pl: 4, + pl: 4.75, pb: 2, } : level === 1 ? { - ml: 2.5, - pl: '11px', - pb: 2, + ml: 3.5, + pl: 1, borderLeft: 1, borderColor: theme.palette.divider, } : {}, - listItemIcon: { - minWidth: 'auto', - }, listItemDivider: { px: drawerOpen ? 2 : 1, }, divider: { flex: 1, }, - text: { - pl: 2, - - [`&:hover .${listItemTextClasses.secondary}`]: { - color: 'inherit', - }, - - [`.${listItemTextClasses.secondary}`]: { - ...theme.typography.helperText, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - - color: active ? 'inherit' : theme.palette.text.disabled, - }, + listItemButton: { + px: 2, }, textOnly: { m: 0, diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.test.tsx similarity index 88% rename from ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx rename to ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.test.tsx index 56a068776a8..875deb49373 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.test.tsx @@ -1,10 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { wrapWithRouter } from 'utils/testUtils'; -import NavItem from './NavItem'; -import { NavItemProps } from './NavItem.types'; +import SidebarNavItem from './SidebarNavItem'; +import { NavItemProps } from './SidebarNavItem.types'; import { NavItem as NavTreeItem } from 'types/navigation.types'; import { collapseClasses } from '@mui/material/Collapse'; import { MemoryRouterProps } from 'react-router-dom'; +import { ThemeContextProvider, pmmThemeOptions } from '@percona/percona-ui'; const TEST_NAV_TREE: NavTreeItem = { id: 'level-0', @@ -44,19 +45,21 @@ const renderNavItem = ({ activeItem?: NavTreeItem; } = {}) => render( - wrapWithRouter( - , - routerProps - ) + + {wrapWithRouter( + , + routerProps + )} + ); -describe('NavItem', () => { +describe('SidebarNavItem', () => { it('inner levels are closed by default', () => { renderNavItem(); @@ -157,14 +160,14 @@ describe('NavItem', () => { }, ], }; - renderNavItem({ + const { container } = renderNavItem({ activeItem: item, props: { item }, }); fireEvent.click(screen.getByTestId('navitem-with-badge-toggle')); - expect(screen.getByTestId('navitem-dot')).toBeInTheDocument(); + expect(container.querySelector('.MuiBadge-dot')).toBeInTheDocument(); }); it('renders divider if item has type "menu-divider"', () => { diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.tsx similarity index 72% rename from ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx rename to ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.tsx index 2cc2ad840a4..41142a8b4f7 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.tsx @@ -1,12 +1,11 @@ import { useLinkWithVariables } from 'hooks/utils/useLinkWithVariables'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { NavItemProps } from './NavItem.types'; +import { NavItemProps } from './SidebarNavItem.types'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { getLinkProps, hasChildMatch, shouldShowBadge } from './NavItem.utils'; -import { getStyles } from './NavItem.styles'; +import { getLinkProps, hasChildMatch, shouldShowBadge } from './SidebarNavItem.utils'; +import { getStyles } from './SidebarNavItem.styles'; import { useTheme } from '@mui/material/styles'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; +import { NavItem } from '@percona/percona-ui'; import ListItemText from '@mui/material/ListItemText'; import Stack from '@mui/material/Stack'; import ListItem from '@mui/material/ListItem'; @@ -14,14 +13,12 @@ import List from '@mui/material/List'; import Collapse from '@mui/material/Collapse'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; -import Box from '@mui/material/Box'; import NavItemIcon from './nav-item-icon/NavItemIcon'; import NavItemTooltip from './nav-item-tooltip/NavItemTooltip'; import { DRAWER_WIDTH } from '../drawer/Drawer.constants'; -import NavItemDot from './nav-item-dot/NavItemDot'; import NavItemBadge from './nav-item-badge/NavItemBadge'; -const NavItem: FC = ({ +const SidebarNavItem: FC = ({ activeItem, item, drawerOpen, @@ -38,7 +35,7 @@ const NavItem: FC = ({ ); const linkProps = getLinkProps(item, url); const theme = useTheme(); - const styles = getStyles(theme, active, drawerOpen, level); + const styles = getStyles(theme, drawerOpen, level); const dataTestid = `navitem-${item.id}`; const showBadge = shouldShowBadge(item, open); @@ -93,38 +90,29 @@ const NavItem: FC = ({ alignItems="center" justifyContent="space-between" sx={{ - width: level === 0 ? DRAWER_WIDTH : undefined, + width: level === 0 ? DRAWER_WIDTH : '100%', }} data-testid={dataTestid + '-list-item'} > - : undefined} + showDot={showBadge && !!item.icon} + badge={ + item.badge && item.badgeAlwaysVisible && drawerOpen + ? + : undefined + } + selected={active} sx={[ - styles.listItemButton, level === 0 && styles.navItemRootCollapsible, + !drawerOpen && { justifyContent: 'center' }, ]} + {...(linkProps as Omit & { component?: React.ElementType })} onClick={handleOpenCollapsible} - {...linkProps} data-testid={dataTestid} data-navlevel={level} - > - {item.icon && ( - - - - - - )} - - {item.badge && item.badgeAlwaysVisible && drawerOpen && ( - - )} - + /> {drawerOpen && ( = ({ > {item.children.map((item) => ( - = ({ - : undefined} + badge={item.badge ? : undefined} + selected={active} sx={[ - styles.listItemButton, styles.leafItem, level === 0 && styles.navItemRoot, + !drawerOpen && { justifyContent: 'center' }, ]} - selected={active} - {...linkProps} + {...(linkProps as Omit & { component?: React.ElementType })} onClick={handleItemClick} data-testid={dataTestid} data-navlevel={level} - > - {item.icon ? ( - - - - ) : ( - - )} - - {item.badge && } - + /> ); }; -export default NavItem; +export default SidebarNavItem; diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.types.ts b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.types.ts similarity index 100% rename from ui/apps/pmm/src/components/sidebar/nav-item/NavItem.types.ts rename to ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.types.ts diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.utils.ts similarity index 96% rename from ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts rename to ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.utils.ts index c57449c3f89..cf4b01ebdcc 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts +++ b/ui/apps/pmm/src/components/sidebar/nav-item/SidebarNavItem.utils.ts @@ -8,7 +8,7 @@ export const getLinkProps = (item: NavItem, url?: string) => { if (item.target && item.url) { return { - component: 'a', + component: 'a' as const, target: item.target, href: url, }; diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/index.ts b/ui/apps/pmm/src/components/sidebar/nav-item/index.ts index 559575122e4..aed47979f83 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/index.ts +++ b/ui/apps/pmm/src/components/sidebar/nav-item/index.ts @@ -1 +1 @@ -export { default as NavItem } from './NavItem'; +export { default as SidebarNavItem } from './SidebarNavItem'; diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-dot/NavItemDot.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-dot/NavItemDot.tsx deleted file mode 100644 index 300b5513bdf..00000000000 --- a/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-dot/NavItemDot.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import Badge from '@mui/material/Badge'; -import { FC, memo, PropsWithChildren } from 'react'; - -interface Props extends PropsWithChildren { - show: boolean; -} - -const NavItemDot: FC = memo(({ show, children }) => - show ? ( - - {children} - - ) : ( - <>{children} - ) -); - -export default NavItemDot; diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts index 0399e2b8ca3..52fc0225460 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts +++ b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts @@ -507,6 +507,12 @@ export const NAV_ALERTS_GROUPS: NavItem = { url: `${PMM_NEW_NAV_GRAFANA_PATH}/alerting/groups`, }; +export const NAV_ALERTS_NODES: NavItem = { + id: 'alerts-nodes', + text: 'Alerts', + url: `${PMM_NEW_NAV_PATH}/alerting/node-alerts`, +}; + export const NAV_ALERTS_CONTACT_POINTS: NavItem = { id: 'alerts-contact-points', text: 'Contact points', @@ -530,7 +536,7 @@ export const NAV_ALERTS: NavItem = { id: 'alerts', icon: 'alerts', text: 'Alerts', - url: `${PMM_NEW_NAV_GRAFANA_PATH}/alerting/alerts`, + url: `${PMM_NEW_NAV_GRAFANA_PATH}/alerting/node-alerts`, }; export const NAV_ADVISORS: NavItem = { diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx b/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx index 8f715e7a7ce..fea70c9a7b8 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx +++ b/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx @@ -16,6 +16,7 @@ import { NAV_ALERTS_NOTIFICATION_POLICIES, NAV_ALERTS_SETTINGS, NAV_ALERTS_TEMPLATES, + NAV_ALERTS_NODES, NAV_CHANGE_PASSWORD, NAV_CONFIGURATION, NAV_DASHBOARDS, @@ -137,6 +138,7 @@ export const addAlerting = (enabled = false, user?: User): NavItem => { const children: NavItem[] = []; if (enabled) { + children.push(NAV_ALERTS_NODES); children.push(NAV_ALERTS_FIRED); } diff --git a/ui/apps/pmm/src/hooks/api/usePrometheusAlertRules.ts b/ui/apps/pmm/src/hooks/api/usePrometheusAlertRules.ts new file mode 100644 index 00000000000..ac8226ab9ec --- /dev/null +++ b/ui/apps/pmm/src/hooks/api/usePrometheusAlertRules.ts @@ -0,0 +1,14 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { getPrometheusAlertRules } from 'api/alerting'; +import { PrometheusAlertRulesResponse } from 'types/alerting.types'; + +export const PROMETHEUS_ALERT_RULES_QUERY_KEY = ['alerting:prometheusRules']; + +export const usePrometheusAlertRules = ( + options?: Partial> +) => + useQuery({ + queryKey: PROMETHEUS_ALERT_RULES_QUERY_KEY, + queryFn: () => getPrometheusAlertRules(), + ...options, + }); diff --git a/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.constants.ts b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.constants.ts new file mode 100644 index 00000000000..67f049778e0 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.constants.ts @@ -0,0 +1,42 @@ +import { TextSelectOption } from 'components/text-select/TextSelect.types'; +import { AlertStatus } from 'types/alerting.types'; + +export const ALL_STATES_FILTER = '__all_states__'; + +export const STATUS_COLOR_MAP: Record< + AlertStatus, + 'default' | 'error' | 'warning' | 'success' +> = { + Alerting: 'error', + Pending: 'warning', + Normal: 'success', + NoData: 'default', + Error: 'error', +}; + +export const STATUS_LABEL_MAP: Record = { + Alerting: 'Firing', + Pending: 'Pending', + Normal: 'Normal', + NoData: 'No Data', + Error: 'Error', +}; + +export const STATE_OPTIONS: TextSelectOption[] = [ + { + label: 'All', + value: ALL_STATES_FILTER, + }, + { + label: 'Normal', + value: 'Normal', + }, + { + label: 'Pending', + value: 'Pending', + }, + { + label: 'Firing', + value: 'Alerting', + }, +]; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.tsx b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.tsx new file mode 100644 index 00000000000..285db377242 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.tsx @@ -0,0 +1,525 @@ +import { useEffect, useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { tz } from '@date-fns/tz'; +import { Link as RouterLink } from 'react-router-dom'; +import { Table } from '@percona/percona-ui'; +import { type MRT_ColumnDef } from 'material-react-table'; +import { + Alert, + Button, + Card, + CardContent, + Chip, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Skeleton, + Stack, + Switch, + Typography, +} from '@mui/material'; +import { paperClasses } from '@mui/material/Paper'; +import { useUser } from 'contexts/user'; +import { usePrometheusAlertRules } from 'hooks/api/usePrometheusAlertRules'; +import { TIME_FORMAT } from 'lib/constants'; +import { AlertRow, AlertsTableRow } from './AlertsPage.types'; +import { + ALL_SERVICES_FILTER, + ALL_NODES_FILTER, + filterAlertRulesByNode, + filterAlertRulesByService, + flattenAlertRules, + getServiceFilterOptionsForNode, + groupAlertsByNode, + getNodeFilterOptions, +} from './AlertsPage.utils'; +import { + ALL_STATES_FILTER, + STATE_OPTIONS, + STATUS_COLOR_MAP, + STATUS_LABEL_MAP, +} from './AlertsPage.constants'; +import { AlertDetailsPane } from './details-pane'; + +const createAlertRuleViewUrl = (ruleGroupUid: string) => + `/graph/alerting/grafana/${ruleGroupUid}/view`; +const createAlertRuleEditUrl = (ruleGroupUid: string) => + `/graph/alerting/${ruleGroupUid}/edit`; + +const formatTimestamp = (timestamp: string | undefined, timezone: string) => { + if (!timestamp) { + return '-'; + } + + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return '-'; + } + + return format(date, TIME_FORMAT, { in: tz(timezone) }); +}; + +const AlertsPage = () => { + const { user } = useUser(); + const timezone = user?.preferences?.timezone || 'UTC'; + const { data, isLoading, isError, error, refetch, isRefetching } = + usePrometheusAlertRules({ + refetchInterval: 5000, + }); + const [isGroupedByNode, setIsGroupedByNode] = useState(false); + const [selectedNode, setSelectedNode] = useState(ALL_NODES_FILTER); + const [selectedService, setSelectedService] = + useState(ALL_SERVICES_FILTER); + const [selectedState, setSelectedState] = useState(ALL_STATES_FILTER); + const [selectedAlert, setSelectedAlert] = useState(); + const [selectedAlertIndex, setSelectedAlertIndex] = useState(); + const rows = useMemo(() => flattenAlertRules(data), [data]); + const nodeOptions = useMemo(() => getNodeFilterOptions(rows), [rows]); + const nodeFilteredRows = useMemo( + () => filterAlertRulesByNode(rows, selectedNode), + [rows, selectedNode] + ); + const isServiceFilterDisabled = selectedNode === ALL_NODES_FILTER; + const serviceOptions = useMemo( + () => + isServiceFilterDisabled + ? [{ value: ALL_SERVICES_FILTER, label: 'All services' }] + : getServiceFilterOptionsForNode(rows, selectedNode), + [rows, selectedNode, isServiceFilterDisabled] + ); + + useEffect(() => { + if (isServiceFilterDisabled && selectedService !== ALL_SERVICES_FILTER) { + setSelectedService(ALL_SERVICES_FILTER); + return; + } + + if (!isServiceFilterDisabled) { + const selectedServiceExists = serviceOptions.some( + (option) => option.value === selectedService + ); + + if (!selectedServiceExists) { + setSelectedService(ALL_SERVICES_FILTER); + } + } + }, [isServiceFilterDisabled, selectedService, serviceOptions]); + + const filteredRows = useMemo(() => { + const rows = isServiceFilterDisabled + ? nodeFilteredRows + : filterAlertRulesByService(nodeFilteredRows, selectedService); + + if (selectedState !== ALL_STATES_FILTER) { + return rows.filter((r) => r.state === selectedState); + } + + return rows; + }, [ + nodeFilteredRows, + selectedService, + isServiceFilterDisabled, + selectedState, + ]); + const tableRows = useMemo( + () => (isGroupedByNode ? groupAlertsByNode(filteredRows) : filteredRows), + [filteredRows, isGroupedByNode] + ); + + useEffect(() => { + if (!selectedAlert) { + return; + } + + const nextIndex = filteredRows.findIndex( + (row) => row.id === selectedAlert.id + ); + + if (nextIndex === -1) { + setSelectedAlert(undefined); + setSelectedAlertIndex(undefined); + return; + } + + if (filteredRows[nextIndex] !== selectedAlert) { + setSelectedAlert(filteredRows[nextIndex]); + setSelectedAlertIndex(nextIndex); + } + }, [filteredRows, selectedAlert]); + + const handleAlertChange = (alert: AlertRow) => { + setSelectedAlert(alert); + setSelectedAlertIndex(filteredRows.findIndex((row) => row.id === alert.id)); + }; + + const handleCloseDetails = () => { + setSelectedAlert(undefined); + setSelectedAlertIndex(undefined); + }; + + const handleNextAlert = () => { + const idx = (selectedAlertIndex ?? -1) + 1; + + if (idx >= filteredRows.length) { + return; + } + + handleAlertChange(filteredRows[idx]); + }; + + const handlePreviousAlert = () => { + const idx = (selectedAlertIndex ?? 0) - 1; + + if (idx < 0) { + return; + } + + handleAlertChange(filteredRows[idx]); + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'state', + header: 'State', + size: 120, + Cell: ({ row }) => { + if (row.original.type === 'node') { + return ''; + } + + const status = row.original.state; + + return ( + + ); + }, + }, + { + accessorKey: 'alertName', + header: 'Alert', + Cell: ({ row }) => + row.original.type === 'node' + ? `${row.original.nodeId} (${row.original.alertCount})` + : row.original.alertName, + }, + { + accessorKey: 'nodeId', + header: 'Node', + Cell: ({ row }) => + row.original.type === 'node' && isGroupedByNode + ? '-' + : row.original.nodeId || '-', + }, + { + accessorKey: 'serviceName', + header: 'Service', + Cell: ({ row }) => + row.original.type === 'node' ? '-' : row.original.serviceName || '-', + }, + { + accessorKey: 'activeAt', + header: 'Active since', + Cell: ({ row }) => + row.original.type === 'node' + ? '-' + : formatTimestamp(row.original.activeAt, timezone), + }, + { + accessorKey: 'age', + header: 'Age', + Cell: ({ row }) => + row.original.type === 'node' ? '-' : row.original.age, + }, + ], + [isGroupedByNode, timezone] + ); + + return ( + + Alerts + + + + {isLoading && } + {isError && ( + refetch()} + disabled={isRefetching} + > + Retry + + } + > + Failed to load alert rules: {error?.message || 'unknown error'} + + )} + {!isLoading && !isError && rows.length === 0 && ( + + No alerts were returned by Prometheus alert rules. + + )} + {!isLoading && !isError && rows.length > 0 && ( + .${paperClasses.root}`]: { + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + }, + }} + > + + + + Node + + + + Service + + + + State + + + setIsGroupedByNode(checked)} + /> + } + label="Group by node" + sx={{ ml: 0 }} + /> + + + column.accessorKey || ''), + 'mrt-row-actions', + ], + }} + tableName="alerts" + columns={columns} + data={tableRows} + noDataMessage="No alerts for selected filters." + enableHiding={false} + enableGlobalFilter={false} + enableFilters={false} + enableStickyHeader + enableExpanding={isGroupedByNode} + enableExpandAll={isGroupedByNode} + enableColumnActions={false} + enableColumnOrdering={false} + enableColumnDragging={false} + enableTopToolbar={false} + enableRowActions + displayColumnDefOptions={{ + 'mrt-row-actions': { + header: '', + size: 48, + minSize: 48, + maxSize: 48, + muiTableHeadCellProps: { + sx: { + width: 48, + minWidth: 48, + px: 0.5, + }, + }, + muiTableBodyCellProps: { + sx: { + width: 48, + minWidth: 48, + px: 0.5, + }, + }, + }, + }} + renderRowActionMenuItems={({ row, closeMenu }) => { + if ( + row.original.type !== 'alert' || + !row.original.ruleGroupUid + ) { + return []; + } + + return [ + { + event.stopPropagation(); + closeMenu(); + }} + > + View rule + , + { + event.stopPropagation(); + closeMenu(); + }} + > + Edit rule + , + ]; + }} + getRowId={(row) => row.id} + getSubRows={(row) => + row.type === 'node' ? row.alerts : undefined + } + muiTableBodyRowProps={({ row }) => { + if (row.original.type === 'alert') { + return { + sx: { + cursor: 'pointer', + '& td': { + fontWeight: 400, + }, + }, + }; + } + + return { + sx: { + cursor: 'default', + '& td': { + fontWeight: 600, + }, + }, + }; + }} + muiTableBodyCellProps={({ row }) => { + if (row.original.type !== 'alert') { + return {}; + } + + const alert = row.original; + + return { + onClick: () => handleAlertChange(alert), + }; + }} + muiTableHeadRowProps={{ + sx: { + boxShadow: 'none', + }, + }} + /> + + )} + + + + + + ); +}; + +export default AlertsPage; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.types.ts b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.types.ts new file mode 100644 index 00000000000..c8854b255c7 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.types.ts @@ -0,0 +1,32 @@ +import { AlertStatus } from 'types/alerting.types'; + +export interface AlertRow { + type: 'alert'; + id: string; + alertName: string; + ruleName: string; + ruleGroupUid?: string; + state: AlertStatus; + nodeId: string; + serviceName: string; + summary: string; + source: string; + labels: Record; + annotations: Record; + expression: string; + value?: string; + activeAt?: string; + age: string; + rawJson: string; +} + +export interface NodeGroupRow { + type: 'node'; + id: string; + nodeId: string; + state: AlertStatus; + alertCount: number; + alerts: AlertRow[]; +} + +export type AlertsTableRow = AlertRow | NodeGroupRow; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.test.ts b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.test.ts new file mode 100644 index 00000000000..2853b2e5e2a --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.test.ts @@ -0,0 +1,295 @@ +import { PrometheusAlertRulesResponse } from 'types/alerting.types'; +import { + ALL_SERVICES_FILTER, + ALL_NODES_FILTER, + filterAlertRulesByNode, + filterAlertRulesByService, + flattenAlertRules, + getServiceFilterOptionsForNode, + groupAlertsByNode, + getNodeFilterOptions, + getServiceFilterOptions, +} from './AlertsPage.utils'; +import { AlertRow } from './AlertsPage.types'; + +const createAlertRow = ( + row: Omit & + Partial> +): AlertRow => ({ + labels: {}, + annotations: {}, + expression: '', + rawJson: '{}', + ...row, +}); + +describe('flattenAlertRules', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns alert rows derived from rules and alerts', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-15T12:00:00.000Z')); + + const payload: PrometheusAlertRulesResponse = { + data: { + groups: [ + { + name: 'mysql-group', + rules: [ + { + name: 'mysql_replication_lag', + alerts: [ + { + state: 'pending', + activeAt: '2026-04-15T11:50:00.000Z', + labels: { + node_name: 'node-a', + alertname: 'MySQL Replication Delay', + service_name: 'mysql-service-a', + }, + annotations: { + summary: 'Replica lag detected', + }, + }, + { + state: 'firing', + activeAt: '2026-04-15T11:40:00.000Z', + labels: { + node_name: 'node-a', + alertname: 'MySQL Replication Broken', + service_name: 'mysql-service-a', + }, + annotations: { + summary: 'Replica stopped', + }, + }, + { + state: 'inactive', + labels: { + node_name: 'node-a', + alertname: 'MySQL Connections', + service_name: 'mysql-service-b', + }, + annotations: { + summary: 'Connections are healthy', + }, + }, + ], + }, + { + name: 'mysql_connections', + alerts: [ + { + state: 'pending', + labels: { + node_name: 'node-b', + service_name: 'mysql-service-b', + }, + annotations: {}, + }, + ], + }, + { + name: 'rule_without_alerts', + state: 'inactive', + annotations: { + summary: 'No active alerts', + }, + alerts: [], + }, + ], + }, + ], + }, + }; + + const rows = flattenAlertRules(payload); + + expect(rows).toHaveLength(4); + const mysqlConnectionsAlert = rows.find( + (row) => row.alertName === 'MySQL Connections' + ); + expect(mysqlConnectionsAlert).toBeDefined(); + expect(mysqlConnectionsAlert?.type).toBe('alert'); + expect(mysqlConnectionsAlert?.ruleName).toBe('mysql_replication_lag'); + expect(mysqlConnectionsAlert?.state).toBe('Normal'); + expect(mysqlConnectionsAlert?.nodeId).toBe('node-a'); + expect(mysqlConnectionsAlert?.serviceName).toBe('mysql-service-b'); + expect(mysqlConnectionsAlert?.summary).toBe('Connections are healthy'); + + const replicationBrokenAlert = rows.find( + (row) => row.alertName === 'MySQL Replication Broken' + ); + expect(replicationBrokenAlert).toBeDefined(); + expect(replicationBrokenAlert?.state).toBe('Alerting'); + expect(replicationBrokenAlert?.nodeId).toBe('node-a'); + expect(replicationBrokenAlert?.serviceName).toBe('mysql-service-a'); + expect(replicationBrokenAlert?.age).toBe('20m'); + + const mysqlConnectionsRuleAlert = rows.find( + (row) => row.ruleName === 'mysql_connections' + ); + expect(mysqlConnectionsRuleAlert).toBeDefined(); + expect(mysqlConnectionsRuleAlert?.nodeId).toBe('node-b'); + expect(mysqlConnectionsRuleAlert?.serviceName).toBe('mysql-service-b'); + }); + + it('falls back to unknown-node when node_name label is absent', () => { + const payload: PrometheusAlertRulesResponse = { + data: { + groups: [ + { + rules: [ + { + name: 'generic_alert', + alerts: [ + { + state: 'pending', + labels: { + alertname: 'Generic Alert', + }, + annotations: {}, + }, + ], + }, + ], + }, + ], + }, + }; + + const rows = flattenAlertRules(payload); + + expect(rows).toHaveLength(1); + expect(rows[0].nodeId).toBe(''); + expect(rows[0].serviceName).toBe(''); + }); + + it('builds node/service options and filters by selected values', () => { + const rows = [ + createAlertRow({ + type: 'alert' as const, + id: 'r1', + alertName: 'alert-1', + ruleName: 'rule-1', + state: 'Alerting' as const, + nodeId: 'node-a', + serviceName: 'svc-a', + summary: 's1', + source: 'src', + age: '2m', + }), + createAlertRow({ + type: 'alert' as const, + id: 'r2', + alertName: 'alert-2', + ruleName: 'rule-2', + state: 'Pending' as const, + nodeId: 'node-b', + serviceName: 'svc-b', + summary: 's2', + source: 'src', + age: '3m', + }), + createAlertRow({ + type: 'alert' as const, + id: 'r3', + alertName: 'alert-3', + ruleName: 'rule-3', + state: 'Pending' as const, + nodeId: '', + serviceName: '', + summary: 's3', + source: 'src', + age: '4m', + }), + ]; + + const options = getNodeFilterOptions(rows); + expect(options.map((option) => option.value)).toEqual([ + ALL_NODES_FILTER, + 'node-a', + 'node-b', + ]); + + const allNodeRows = filterAlertRulesByNode(rows, ALL_NODES_FILTER); + expect(allNodeRows).toHaveLength(3); + + const nodeBRows = filterAlertRulesByNode(rows, 'node-b'); + expect(nodeBRows).toHaveLength(1); + expect(nodeBRows[0].id).toBe('r2'); + + const serviceOptions = getServiceFilterOptions(rows); + expect(serviceOptions.map((option) => option.value)).toEqual([ + ALL_SERVICES_FILTER, + 'svc-a', + 'svc-b', + ]); + + const allServicesRows = filterAlertRulesByService(rows, ALL_SERVICES_FILTER); + expect(allServicesRows).toHaveLength(3); + + const serviceBRows = filterAlertRulesByService(rows, 'svc-b'); + expect(serviceBRows).toHaveLength(1); + expect(serviceBRows[0].id).toBe('r2'); + + const nodeBServiceOptions = getServiceFilterOptionsForNode(rows, 'node-b'); + expect(nodeBServiceOptions.map((option) => option.value)).toEqual([ + ALL_SERVICES_FILTER, + 'svc-b', + ]); + }); + + it('groups alert rows by node and exposes child alert rows', () => { + const rows = [ + createAlertRow({ + type: 'alert' as const, + id: 'a1', + alertName: 'alert-1', + ruleName: 'rule-1', + state: 'Pending' as const, + nodeId: 'node-a', + serviceName: 'svc-a', + summary: 's1', + source: 'src', + age: '1m', + }), + createAlertRow({ + type: 'alert' as const, + id: 'a2', + alertName: 'alert-2', + ruleName: 'rule-2', + state: 'Alerting' as const, + nodeId: 'node-a', + serviceName: 'svc-b', + summary: 's2', + source: 'src', + age: '2m', + }), + createAlertRow({ + type: 'alert' as const, + id: 'a3', + alertName: 'alert-3', + ruleName: 'rule-3', + state: 'Normal' as const, + nodeId: 'node-b', + serviceName: 'svc-c', + summary: 's3', + source: 'src', + age: '3m', + }), + ]; + + const grouped = groupAlertsByNode(rows); + + expect(grouped).toHaveLength(2); + expect(grouped[0].type).toBe('node'); + expect(grouped[0].nodeId).toBe('node-a'); + expect(grouped[0].alertCount).toBe(2); + expect(grouped[0].state).toBe('Alerting'); + expect(grouped[0].alerts).toHaveLength(2); + expect(grouped[0].alerts[0].type).toBe('alert'); + }); +}); diff --git a/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.ts b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.ts new file mode 100644 index 00000000000..dc0b945554a --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/AlertsPage.utils.ts @@ -0,0 +1,266 @@ +import { + AlertStatus, + PrometheusAlertItem, + PrometheusAlertRuleItem, + PrometheusAlertState, + PrometheusAlertRulesResponse, +} from 'types/alerting.types'; +import { AlertRow, NodeGroupRow } from './AlertsPage.types'; +import { TextSelectOption } from 'components/text-select/TextSelect.types'; + +const NODE_NAME_LABEL = 'node_name'; +const UNKNOWN_NODE = 'unknown-node'; +export const ALL_NODES_FILTER = '__all_nodes__'; +export const ALL_SERVICES_FILTER = '__all_services__'; + +const GROUP_STATE_PRIORITY: Record = { + Alerting: 4, + Error: 3, + Pending: 2, + NoData: 1, + Normal: 0, +}; + +const ALERT_STATUSES = new Set([ + 'Alerting', + 'Error', + 'Pending', + 'NoData', + 'Normal', +]); + +const isAlertStatus = (state: string): state is AlertStatus => + ALERT_STATUSES.has(state as AlertStatus); + +const mapRuleStateToAlertState = ( + ruleState?: PrometheusAlertState +): AlertStatus => { + switch (ruleState) { + case 'firing': + return 'Alerting'; + case 'pending': + return 'Pending'; + case 'inactive': + return 'Normal'; + default: + return 'NoData'; + } +}; + +const resolveState = ( + alert: PrometheusAlertItem, + rule: PrometheusAlertRuleItem +): AlertStatus => { + if (!alert.state) { + return mapRuleStateToAlertState(rule.state); + } + + return isAlertStatus(alert.state) + ? alert.state + : mapRuleStateToAlertState(alert.state); +}; + +const getAge = (activeAt?: string): string => { + if (!activeAt) { + return '-'; + } + + const timestamp = new Date(activeAt).getTime(); + + if (Number.isNaN(timestamp)) { + return '-'; + } + + const diffMs = Math.max(Date.now() - timestamp, 0); + const diffMinutes = Math.floor(diffMs / 60000); + + if (diffMinutes < 1) { + return '<1m'; + } + + if (diffMinutes < 60) { + return `${diffMinutes}m`; + } + + const diffHours = Math.floor(diffMinutes / 60); + + if (diffHours < 24) { + return `${diffHours}h`; + } + + const diffDays = Math.floor(diffHours / 24); + + return `${diffDays}d`; +}; + +const getSource = (labels: Record) => + labels.service_name || labels.service || labels.job || labels.instance || '-'; + +const getServiceName = (labels: Record) => + labels.service_name || labels.service || '-'; + +const getSummary = (alert: PrometheusAlertItem) => + alert.annotations.summary || alert.annotations.description || '-'; + +const getNodeName = (labels: Record) => + labels[NODE_NAME_LABEL] || UNKNOWN_NODE; + +const getAlertName = ( + alert: PrometheusAlertItem, + rule: PrometheusAlertRuleItem +) => alert.labels.alertname || rule.name || 'Unnamed alert'; + +const getAlertNodeId = (alert: PrometheusAlertItem): string => { + const nodeId = getNodeName(alert.labels); + return nodeId === UNKNOWN_NODE ? '' : nodeId; +}; + +const getAlertServiceName = (alert: PrometheusAlertItem): string => { + const serviceName = getServiceName(alert.labels); + return serviceName === '-' ? '' : serviceName; +}; + +export const flattenAlertRules = ( + data?: PrometheusAlertRulesResponse +): AlertRow[] => { + if (!data?.data.groups.length) { + return []; + } + + const rows = data.data.groups.flatMap((group) => + (group.rules || []).flatMap((rule) => + (rule.alerts || []).map((alert) => { + const ruleDetails = { + name: rule.name, + query: rule.query, + duration: rule.duration, + labels: rule.labels, + annotations: rule.annotations, + health: rule.health, + lastError: rule.lastError, + type: rule.type, + state: rule.state, + }; + + return { + type: 'alert' as const, + id: `${rule.name}-${alert.labels.node_name}-${alert.labels.service_name}`, + alertName: getAlertName(alert, rule), + ruleName: rule.name || 'Unnamed rule', + ruleGroupUid: rule.uid, + state: resolveState(alert, rule), + nodeId: getAlertNodeId(alert), + serviceName: getAlertServiceName(alert), + summary: getSummary(alert), + source: getSource(alert.labels), + labels: alert.labels, + annotations: alert.annotations, + expression: rule.query || '', + value: alert.value, + activeAt: alert.activeAt, + age: getAge(alert.activeAt), + rawJson: JSON.stringify( + { + rule: ruleDetails, + alert, + }, + null, + 2 + ), + }; + }) + ) + ); + + return rows.sort((a, b) => + `${a.ruleName}:${a.alertName}`.localeCompare(`${b.ruleName}:${b.alertName}`) + ); +}; + +export const groupAlertsByNode = (rows: AlertRow[]): NodeGroupRow[] => { + const grouped = new Map(); + + for (const row of rows) { + const nodeId = row.nodeId || UNKNOWN_NODE; + const nodeRows = grouped.get(nodeId) || []; + nodeRows.push(row); + grouped.set(nodeId, nodeRows); + } + + const groupedRows = [...grouped.entries()] + .map(([nodeId, alerts]) => { + const state = alerts + .map((alert) => alert.state) + .sort((a, b) => GROUP_STATE_PRIORITY[b] - GROUP_STATE_PRIORITY[a])[0]; + + return { + type: 'node' as const, + id: `node:${nodeId}`, + nodeId, + state, + alertCount: alerts.length, + alerts: alerts.sort((a, b) => + `${a.ruleName}:${a.alertName}`.localeCompare( + `${b.ruleName}:${b.alertName}` + ) + ), + }; + }) + .sort((a, b) => a.nodeId.localeCompare(b.nodeId)); + + return groupedRows; +}; + +export const getNodeFilterOptions = ( + rows: AlertRow[] +): TextSelectOption[] => { + const knownNodeIds = [ + ...new Set(rows.map((row) => row.nodeId).filter(Boolean)), + ].sort(); + + return [ + { value: ALL_NODES_FILTER, label: 'All nodes' }, + ...knownNodeIds.map((nodeId) => ({ value: nodeId, label: nodeId })), + ]; +}; + +export const filterAlertRulesByNode = ( + rows: AlertRow[], + selectedNode: string +): AlertRow[] => { + if (selectedNode === ALL_NODES_FILTER) { + return rows; + } + + return rows.filter((row) => row.nodeId === selectedNode); +}; + +export const getServiceFilterOptions = ( + rows: AlertRow[] +): TextSelectOption[] => { + const serviceNames = [ + ...new Set(rows.map((row) => row.serviceName).filter(Boolean)), + ].sort(); + + return [ + { value: ALL_SERVICES_FILTER, label: 'All' }, + ...serviceNames.map((service) => ({ value: service, label: service })), + ]; +}; + +export const getServiceFilterOptionsForNode = ( + rows: AlertRow[], + selectedNode: string +): TextSelectOption[] => + getServiceFilterOptions(filterAlertRulesByNode(rows, selectedNode)); + +export const filterAlertRulesByService = ( + rows: AlertRow[], + selectedService: string +): AlertRow[] => { + if (selectedService === ALL_SERVICES_FILTER) { + return rows; + } + + return rows.filter((row) => row.serviceName === selectedService); +}; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetails.tsx b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetails.tsx new file mode 100644 index 00000000000..19750a46ab2 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetails.tsx @@ -0,0 +1,269 @@ +import { FC, ReactNode } from 'react'; +import { format } from 'date-fns'; +import { tz } from '@date-fns/tz'; +import { + Chip, + Grid, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useUser } from 'contexts/user'; +import { TIME_FORMAT } from 'lib/constants'; +import { SyntaxHighlighter } from 'components/syntax-highlighter'; +import BigNumberMetric from 'pages/rta/overview/details-pane/BigNumberMetric'; +import DetailsMetric from 'pages/rta/overview/details-pane/DetailsMetric'; +import { STATUS_COLOR_MAP, STATUS_LABEL_MAP } from '../AlertsPage.constants'; +import { AlertRow } from '../AlertsPage.types'; + +type Props = { + alert: AlertRow; +}; + +type GridItemProps = { + children: ReactNode; + size?: { + xs: number; + md?: number; + }; +}; + +const GridItem = ({ children, size = { xs: 6 } }: GridItemProps) => ( + *': { height: '100%' } }}> + {children} + +); + +const formatTimestamp = (timestamp: string | undefined, timezone: string) => { + if (!timestamp) { + return undefined; + } + + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return format(date, TIME_FORMAT, { in: tz(timezone) }); +}; + +const KeyValueTable = ({ + title, + data, +}: { + title: string; + data: Record; +}) => { + const entries = Object.entries(data); + + return ( + +
+ + + + + {title} + + + + + + {entries.length ? ( + entries.map(([key, value]) => ( + + + {key} + + + {value} + + + )) + ) : ( + + No {title.toLowerCase()}. + + )} + +
+ + ); +}; + +const AlertDetails: FC = ({ alert }) => { + const { user } = useUser(); + const timezone = user?.preferences?.timezone || 'UTC'; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Query + + + + + + Value + + + + + + + + + ); +}; + +export default AlertDetails; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.messages.ts b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.messages.ts new file mode 100644 index 00000000000..25db9aaf782 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.messages.ts @@ -0,0 +1,16 @@ +export const Messages = { + tabs: { + details: 'Details', + rawData: 'Raw data', + }, + actions: { + previous: 'Previous alert', + next: 'Next alert', + close: 'Close details pane', + }, + tooltips: { + previous: 'Previous alert', + next: 'Next alert', + close: 'Close details', + }, +}; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.tsx b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.tsx new file mode 100644 index 00000000000..6291671d90b --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/AlertDetailsPane.tsx @@ -0,0 +1,149 @@ +import { FC, useState } from 'react'; +import CardContent from '@mui/material/CardContent'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Slide from '@mui/material/Slide'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; +import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined'; +import { Icon } from 'components/icon'; +import { SyntaxHighlighter } from 'components/syntax-highlighter'; +import { useEscapeKey } from 'utils/keys.utils'; +import { AlertRow } from '../AlertsPage.types'; +import AlertDetails from './AlertDetails'; +import { Messages } from './AlertDetailsPane.messages'; + +interface Props { + alert?: AlertRow; + isFirstAlert: boolean; + isLastAlert: boolean; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; +} + +const AlertDetailsPane: FC = ({ + alert, + isFirstAlert, + isLastAlert, + onClose, + onNext, + onPrevious, +}) => { + const [tab, setTab] = useState<'details' | 'raw-data'>('details'); + + const handleClose = () => { + onClose(); + setTab('details'); + }; + + useEscapeKey(handleClose); + + return ( + + ({ + pb: 1, + px: 3, + top: -16, + left: -16, + right: -16, + m: 2, + bottom: theme.spacing(-2), + position: 'absolute', + overflow: 'scroll', + zIndex: theme.zIndex.modal, + })} + > + + setTab(newValue)}> + + + + + + + + + + + + + + + + + + + + + + {alert ? ( + + {tab === 'details' && } + {tab === 'raw-data' && ( + + )} + + ) : null} + + + ); +}; + +export default AlertDetailsPane; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/details-pane/index.ts b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/index.ts new file mode 100644 index 00000000000..fa94d3b3263 --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/details-pane/index.ts @@ -0,0 +1 @@ +export { default as AlertDetailsPane } from './AlertDetailsPane'; diff --git a/ui/apps/pmm/src/pages/alerting/alerts/index.ts b/ui/apps/pmm/src/pages/alerting/alerts/index.ts new file mode 100644 index 00000000000..8616791002c --- /dev/null +++ b/ui/apps/pmm/src/pages/alerting/alerts/index.ts @@ -0,0 +1 @@ +export { default as AlertsPage } from './AlertsPage'; diff --git a/ui/apps/pmm/src/router.tsx b/ui/apps/pmm/src/router.tsx index 12cdba5d678..54508693b51 100644 --- a/ui/apps/pmm/src/router.tsx +++ b/ui/apps/pmm/src/router.tsx @@ -13,6 +13,7 @@ import { RealtimeSessionsPage } from 'pages/rta/sessions'; import { Redirect, SettingsRedirect } from 'components/redirect'; import RealtimeOverviewPage from 'pages/rta/overview/RealtimeOverview'; import RealtimeTab from 'pages/rta/tab/RealtimeTab'; +import { AlertsPage } from 'pages/alerting/alerts'; const router = createBrowserRouter( [ @@ -40,6 +41,15 @@ const router = createBrowserRouter( path: 'help', element: , }, + { + path: 'alerting', + children: [ + { + path: 'node-alerts', + element: , + }, + ], + }, { path: 'settings/:tab?', element: , diff --git a/ui/apps/pmm/src/types/alerting.types.ts b/ui/apps/pmm/src/types/alerting.types.ts new file mode 100644 index 00000000000..380eb48f3b7 --- /dev/null +++ b/ui/apps/pmm/src/types/alerting.types.ts @@ -0,0 +1,47 @@ +export type PrometheusAlertState = 'firing' | 'pending' | 'inactive'; + +export type AlertStatus = + | 'Alerting' + | 'Normal' + | 'Pending' + | 'NoData' + | 'Error'; + +export interface PrometheusAlertItem { + labels: Record; + annotations: Record; + state?: AlertStatus | PrometheusAlertState; + activeAt?: string; + value?: string; +} + +export interface PrometheusAlertRuleItem { + uid?: string; + name: string; + query?: string; + duration?: number; + labels?: Record; + annotations?: Record; + alerts: PrometheusAlertItem[]; + health?: string; + lastError?: string; + type?: string; + state?: PrometheusAlertState; +} + +export interface PrometheusAlertRuleGroup { + uid?: string; + name?: string; + file?: string; + interval?: number; + rules: PrometheusAlertRuleItem[]; +} + +export interface PrometheusAlertRulesData { + groups: PrometheusAlertRuleGroup[]; +} + +export interface PrometheusAlertRulesResponse { + status?: string; + data: PrometheusAlertRulesData; +} diff --git a/ui/yarn.lock b/ui/yarn.lock index 557fe8c8344..8b5640b838c 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1620,10 +1620,10 @@ resolved "https://registry.yarnpkg.com/@percona/eslint-config-react/-/eslint-config-react-1.1.0.tgz#db69fcf5d6bb43e6c842f462e2c1c84d5f3eb281" integrity sha512-Tk9mNYslXrxW/gdS/rzxHbLuEXoNCw8r2t/pB8CXZjBl46VLMGspljbyI6525uNNfyO5H0iJQKCJu0Ckf+5erA== -"@percona/percona-ui@1.0.16": - version "1.0.16" - resolved "https://registry.yarnpkg.com/@percona/percona-ui/-/percona-ui-1.0.16.tgz#103d2611458890100963089c8729d447c28c39e7" - integrity sha512-cHdwPimD1MPHv0RCuGIp6XAgBvhlEpIP6ybTNfEs4pLi/B3ukh0h7rRLUiGck+ocj1TEFsJSzv3ayhFFYCJVhw== +"@percona/percona-ui@1.0.18": + version "1.0.18" + resolved "https://registry.yarnpkg.com/@percona/percona-ui/-/percona-ui-1.0.18.tgz#e8597d7a66688a8b42ec48de1d9dd588169ae294" + integrity sha512-EALeOHvnp0gXoD9fqFjM+UVgWDloaymhoNNdb0Uv/LfqZALCU4c0ez8VAOLXw4Jhow/TkVM50lFuq6/NqlVqMQ== dependencies: "@fontsource/poppins" "^5.2.7" "@fontsource/roboto" "^5.2.9"