diff --git a/src/components/CheckList/checkFilters.ts b/src/components/CheckList/checkFilters.ts index d64d3edda..b93493a99 100644 --- a/src/components/CheckList/checkFilters.ts +++ b/src/components/CheckList/checkFilters.ts @@ -7,11 +7,10 @@ const matchesFilterType = (check: Check, typeFilter: CheckTypeFilter) => { if (typeFilter === 'all') { return true; } + const checkType = getCheckType(check.settings); - if (checkType === typeFilter) { - return true; - } - return false; + + return checkType === typeFilter; }; const matchesSearchFilter = ({ target, job, labels }: Check, searchFilter: string) => { @@ -36,37 +35,33 @@ const matchesLabelFilter = ({ labels }: Check, labelFilters: string[]) => { if (!labelFilters || labelFilters.length === 0) { return true; } - const result = labels?.some(({ name, value }) => { - const filtersResult = labelFilters.some((filter) => { - return filter === `${name}: ${value}`; - }); - return filtersResult; + + return labels?.some(({ name, value }) => { + return labelFilters.some((filter) => filter === `${name}: ${value}`); }); - return result; }; const matchesStatusFilter = ({ enabled }: Check, { value }: SelectableValue) => { - if ( + return ( value === CheckEnabledStatus.All || (value === CheckEnabledStatus.Enabled && enabled) || (value === CheckEnabledStatus.Disabled && !enabled) - ) { - return true; - } - return false; + ); }; const matchesSelectedProbes = (check: Check, selectedProbes: SelectableValue[]) => { if (selectedProbes.length === 0) { return true; - } else { - const probeIds = selectedProbes.map((p) => p.value); - return check.probes.some((id) => probeIds.includes(id)); } + + const probeIds = selectedProbes.map((p) => p.value); + + return check.probes.some((id) => probeIds.includes(id)); }; export const matchesAllFilters = (check: Check, checkFilters: CheckFiltersType) => { const { type, search, labels, status, probes } = checkFilters; + return ( Boolean(check.id) && matchesFilterType(check, type) && diff --git a/src/components/ConfirmModal/ConfirmModal.constants.ts b/src/components/ConfirmModal/ConfirmModal.constants.ts new file mode 100644 index 000000000..7393c6f4b --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.constants.ts @@ -0,0 +1 @@ +export const GENERIC_ERROR_MESSAGE = 'Something went wrong. Unable to fulfill the requested action.'; diff --git a/src/components/ConfirmModal/ConfirmModal.tsx b/src/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 000000000..73ad0f92c --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Button, ConfirmModal as GrafanaConfirmModal, ConfirmModalProps, Modal, useTheme2 } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import type { AsyncConfirmModalProps } from './ConfirmModal.types'; + +import { GENERIC_ERROR_MESSAGE } from './ConfirmModal.constants'; +import { getErrorWithFallback, hasOnError } from './ConfirmModal.utils'; + +export function ConfirmModal(props: ConfirmModalProps | AsyncConfirmModalProps) { + if (!('async' in props)) { + return ; + } + + return ; +} + +export function AsyncConfirmModal({ + title, + isOpen, + description, + error: _error, + onDismiss, + body, + dismissText = 'Cancel', + confirmText = 'Confirm', + ...props +}: AsyncConfirmModalProps) { + const [error, setError] = useState(_error); + const [pending, setPending] = useState(false); + const theme2 = useTheme2(); + useEffect(() => { + setError(_error); + }, [_error]); + + useEffect(() => { + if (!isOpen) { + setError(undefined); + } + }, [isOpen]); + + const handleDismiss = () => { + !pending && onDismiss?.(); + }; + + const styles = getStyles(theme2); + + const disabled = pending || error !== undefined; + + const handleConfirm = async () => { + if (pending) { + return; + } + + setPending(true); + + try { + const response = await props.onConfirm?.(); + props.onSuccess?.(response); + handleDismiss(); + } catch (error: any) { + if (hasOnError(props)) { + props.onError(error); + return; + } + + setError(getErrorWithFallback(error, title)); + } finally { + setPending(false); + } + }; + + return ( + + {!!error && ( + +
{error.message ?? GENERIC_ERROR_MESSAGE}
+
+ )} +
{body}
+ {!!description &&
{description}
} + + + + +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + // Same as (grafana/ui) ConfirmModal + modal: css({ + width: '100%', + maxWidth: '500px', + }), + alert: css({ + '& *:first-letter': { + textTransform: 'uppercase', + }, + }), + body: css({ + fontSize: theme.typography.h5.fontSize, + }), + description: css({ + fontSize: theme.typography.body.fontSize, + }), + error: css({ + fontSize: theme.typography.body.fontSize, + color: theme.colors.error.text, + }), +}); diff --git a/src/components/ConfirmModal/ConfirmModal.types.ts b/src/components/ConfirmModal/ConfirmModal.types.ts new file mode 100644 index 000000000..05ff86ac6 --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.types.ts @@ -0,0 +1,14 @@ +import { ConfirmModalProps } from '@grafana/ui'; + +export interface ConfirmError { + name: string; + message: string; +} + +export interface AsyncConfirmModalProps extends Omit { + async: boolean; + error?: ConfirmError; + onSuccess?: (response: unknown) => void; + onError?: (error: ConfirmError) => void; + onConfirm?: () => Promise; +} diff --git a/src/components/ConfirmModal/ConfirmModal.utils.ts b/src/components/ConfirmModal/ConfirmModal.utils.ts new file mode 100644 index 000000000..ec3f74c0d --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.utils.ts @@ -0,0 +1,28 @@ +import type { ConfirmError } from './ConfirmModal.types'; + +import { GENERIC_ERROR_MESSAGE } from './ConfirmModal.constants'; + +export function isErrorLike(error: any): error is ConfirmError { + return ( + error && + typeof error === 'object' && + 'name' in error && + typeof error.name === 'string' && + 'message' in error && + typeof error.message === 'string' + ); +} + +export function getFallbackError(title: string): ConfirmError { + return { name: `${title} error`, message: GENERIC_ERROR_MESSAGE }; +} + +export function getErrorWithFallback(error: any, title: string): ConfirmError { + return isErrorLike(error) ? error : getFallbackError(title); +} + +export function hasOnError(props: { + onError?: (error: ConfirmError) => void; +}): props is { onError: (error: ConfirmError) => void } { + return 'onError' in props && typeof props.onError === 'function'; +} diff --git a/src/components/ConfirmModal/index.ts b/src/components/ConfirmModal/index.ts new file mode 100644 index 000000000..3edf9150a --- /dev/null +++ b/src/components/ConfirmModal/index.ts @@ -0,0 +1,4 @@ +export * from './ConfirmModal'; +export type * from './ConfirmModal.types'; +export * from './ConfirmModal.constants'; +export * from './ConfirmModal.utils'; diff --git a/src/components/DeleteProbeButton/DeleteProbeButton.tsx b/src/components/DeleteProbeButton/DeleteProbeButton.tsx new file mode 100644 index 000000000..2910201b5 --- /dev/null +++ b/src/components/DeleteProbeButton/DeleteProbeButton.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Button, Tooltip } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import type { BackendError, DeleteProbeButtonProps } from './DeleteProbeButton.types'; +import { useDeleteProbe } from 'data/useProbes'; +import { useCanEditProbe } from 'hooks/useCanEditProbe'; +import { ConfirmModal } from 'components/ConfirmModal'; +import { ProbeUsageLink } from 'components/ProbeUsageLink'; + +import { getPrettyError } from './DeleteProbeButton.utils'; + +export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }: DeleteProbeButtonProps) { + const [showDeleteModal, setShowDeleteModal] = useState(false); + const onDeleteSuccess = useCallback(() => { + setShowDeleteModal(false); + _onDeleteSuccess?.(); + }, [_onDeleteSuccess]); + + const { mutateAsync: deleteProbe, isPending } = useDeleteProbe({ onSuccess: onDeleteSuccess }); + const canEdit = useCanEditProbe(probe); + const canDelete = canEdit && !probe.checks.length; + const styles = getStyles(); + const [error, setError] = useState(); + + const handleError = (error: Error | BackendError) => { + if (!error) { + setError(undefined); + } + + setError(getPrettyError(error, probe)); + }; + + const handleOnClick = () => { + setShowDeleteModal(true); + }; + + if (!canDelete) { + const tooltipContent = canEdit ? ( + <> + Unable to delete the probe because it is currently in use. +
+ . + + ) : ( + <> + You do not have sufficient permissions +
+ to delete the probe '{probe.name}'. + + ); + + // Both tooltip component and button prob is used for accessibility reasons + return ( + + + + ); + } + + return ( + <> + + {createPortal( + + Are you sure you want to delete this ({probe.name}) probe? + + } + description="This action cannot be undone." + confirmText="Delete probe" + onConfirm={() => deleteProbe(probe)} + onDismiss={() => setShowDeleteModal(false)} + error={error} + onError={handleError} + />, + document.body + )} + + ); +} + +const getStyles = () => ({ + probeName: css({ + display: 'inline-block', + }), +}); diff --git a/src/components/DeleteProbeButton/DeleteProbeButton.types.ts b/src/components/DeleteProbeButton/DeleteProbeButton.types.ts new file mode 100644 index 000000000..d67a0c507 --- /dev/null +++ b/src/components/DeleteProbeButton/DeleteProbeButton.types.ts @@ -0,0 +1,10 @@ +import { FetchResponse } from '@grafana/runtime'; + +import type { ExtendedProbe } from 'types'; + +export type BackendError = FetchResponse<{ err: string; msg: string }>; + +export interface DeleteProbeButtonProps { + probe: ExtendedProbe; + onDeleteSuccess?: () => void; +} diff --git a/src/components/DeleteProbeButton/DeleteProbeButton.utils.ts b/src/components/DeleteProbeButton/DeleteProbeButton.utils.ts new file mode 100644 index 000000000..bf58ad5d0 --- /dev/null +++ b/src/components/DeleteProbeButton/DeleteProbeButton.utils.ts @@ -0,0 +1,14 @@ +import type { BackendError } from './DeleteProbeButton.types'; +import { Probe } from 'types'; + +export function getPrettyError(error: Error | BackendError, probe: Probe) { + if (!error) { + return undefined; + } + + if ('data' in error && 'err' in error.data && 'msg' in error.data && typeof error.data.msg === 'string') { + return { name: error.data.err, message: error.data.msg.replace(String(probe.id), `'${probe.name}'`) }; + } + + return { name: 'Unknown error', message: 'An unknown error occurred' }; +} diff --git a/src/components/DeleteProbeButton/index.ts b/src/components/DeleteProbeButton/index.ts new file mode 100644 index 000000000..7356b8f8a --- /dev/null +++ b/src/components/DeleteProbeButton/index.ts @@ -0,0 +1 @@ +export * from './DeleteProbeButton'; diff --git a/src/components/ProbeCard/ProbeCard.test.tsx b/src/components/ProbeCard/ProbeCard.test.tsx index 69386692d..cb574312d 100644 --- a/src/components/ProbeCard/ProbeCard.test.tsx +++ b/src/components/ProbeCard/ProbeCard.test.tsx @@ -1,67 +1,141 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; +import { renderHook, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { DataTestIds } from 'test/dataTestIds'; import { OFFLINE_PROBE, ONLINE_PROBE, PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes'; import { render } from 'test/render'; -import { runTestAsViewer } from 'test/utils'; +import { probeToExtendedProbe, runTestAsViewer } from 'test/utils'; -import { ROUTES } from 'types'; +import { type ExtendedProbe, ROUTES } from 'types'; import { getRoute } from 'components/Routing.utils'; import { ProbeCard } from './ProbeCard'; it(`Displays the correct information`, async () => { - const probe = ONLINE_PROBE; + const probe = probeToExtendedProbe(ONLINE_PROBE); render(); + await screen.findByText(probe.name); - expect(screen.getByText(probe.name)).toBeInTheDocument(); + + expect(screen.getByText((content) => content.startsWith(probe.name))).toBeInTheDocument(); expect(screen.getByText(/Version:/)).toBeInTheDocument(); expect(screen.getByText(probe.version, { exact: false })).toBeInTheDocument(); expect(screen.getByText(/Labels:/)).toBeInTheDocument(); for (let i = 0; i < probe.labels.length; i++) { const label = probe.labels[i]; - const labelText = screen.getByText(new RegExp(`${label.name}:${label.value}`)); - expect(labelText).toBeInTheDocument(); + expect(screen.getByText(label.name, { exact: false })).toHaveTextContent(`${label.name}: ${label.value}`); } }); it(`Displays the correct information for an online probe`, async () => { - render(); - const text = await screen.findByText(`Online`); - expect(text).toBeInTheDocument(); + const { result } = renderHook(useTheme2); + const probe = probeToExtendedProbe(ONLINE_PROBE); + + render(); + await screen.findByText(probe.name); + + // Check status circle + const status = screen.getByTestId('probe-online-status'); + expect(status).toBeInTheDocument(); + expect(status).toHaveStyle(`background-color: ${result.current.colors.success.text}`); + + // Check status tooltip + await userEvent.hover(status); + const tooltip = await screen.findByTestId('probe-online-status-tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent(`Probe ${probe.name} is online`); }); it(`Displays the correct information for an offline probe`, async () => { - render(); - const text = await screen.findByText(`Offline`); - expect(text).toBeInTheDocument(); + const { result } = renderHook(useTheme2); + const probe = probeToExtendedProbe(OFFLINE_PROBE); + + render(); + await screen.findByText(probe.name); + + // Check status circle + const status = screen.getByTestId('probe-online-status'); + expect(status).toBeInTheDocument(); + expect(status).toHaveStyle(`background-color: ${result.current.colors.error.text}`); + + // Check status tooltip + await userEvent.hover(status); + const tooltip = await screen.findByTestId('probe-online-status-tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent(`Probe ${probe.name} is offline`); }); it(`Displays the correct information for a private probe`, async () => { - render(); - const text = await screen.findByText(`Private`, { exact: false }); - expect(text).toBeInTheDocument(); - expect(screen.getByText(`Edit`)).toBeInTheDocument(); + const probe = probeToExtendedProbe(PRIVATE_PROBE); + + render(); + await screen.findByText(probe.name, { exact: false }); + + const button = screen.getByTestId('probe-card-action-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Edit'); }); it(`Displays the correct information for a private probe as a viewer`, async () => { runTestAsViewer(); - render(); - const text = await screen.findByText(`View`); - expect(text).toBeInTheDocument(); + const probe = probeToExtendedProbe(PRIVATE_PROBE); + + render(); + await screen.findByText(probe.name, { exact: false }); + + const button = screen.getByTestId('probe-card-action-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('View'); }); it(`Displays the correct information for a public probe`, async () => { - render(); - const text = await screen.findByText(`Public`); - expect(text).toBeInTheDocument(); - expect(screen.getByText(`View`)).toBeInTheDocument(); + const probe = probeToExtendedProbe(PUBLIC_PROBE); + + render(); + await screen.findByText(probe.name, { exact: false }); + + const button = screen.getByTestId('probe-card-action-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('View'); }); it('handles probe click', async () => { - const probe = PRIVATE_PROBE; + const probe = probeToExtendedProbe(PUBLIC_PROBE); const { history, user } = render(); await screen.findByText(probe.name); await user.click(screen.getByText(probe.name)); expect(history.location.pathname).toBe(`${getRoute(ROUTES.EditProbe)}/${probe.id}`); }); + +it.each<[ExtendedProbe, string]>([ + [probeToExtendedProbe(PUBLIC_PROBE, [11]), 'Used in 1 check'], + [probeToExtendedProbe(PRIVATE_PROBE, [11, 22, 33, 44, 55, 66]), 'Used in 6 checks'], +])( + 'Displays the correct information for a probe that is in use', + + async (probe: ExtendedProbe, expectedText: string) => { + const { history, user } = render(); + + await screen.findByText(probe.name); + + const usageLink = screen.getByTestId(DataTestIds.PROBE_USAGE_LINK); + expect(usageLink).toBeInTheDocument(); + expect(usageLink).toHaveTextContent(expectedText); + await user.click(usageLink); + expect(history.location.pathname).toBe(getRoute(ROUTES.Checks)); + expect(history.location.search).toBe(`?probes=${probe.name}`); + } +); + +it('Displays the correct information for a probe that is NOT in use', async () => { + const probe = probeToExtendedProbe(PUBLIC_PROBE); + + render(); + await screen.findByText(probe.name); + + const usageLink = screen.queryByTestId(DataTestIds.PROBE_USAGE_LINK); + expect(usageLink).not.toBeInTheDocument(); +}); diff --git a/src/components/ProbeCard/ProbeCard.tsx b/src/components/ProbeCard/ProbeCard.tsx index 2e1b9579c..fa8292798 100644 --- a/src/components/ProbeCard/ProbeCard.tsx +++ b/src/components/ProbeCard/ProbeCard.tsx @@ -1,152 +1,125 @@ -import React, { useState } from 'react'; +import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Badge, Button, useStyles2 } from '@grafana/ui'; -import { css, cx } from '@emotion/css'; +import { Card, Link, LinkButton, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; -import { type Label, type Probe, ROUTES } from 'types'; +import { type ExtendedProbe, type Label, ROUTES } from 'types'; import { useCanEditProbe } from 'hooks/useCanEditProbe'; -import { Card } from 'components/Card'; import { SuccessRateGaugeProbe } from 'components/Gauges'; import { getRoute } from 'components/Routing.utils'; -export const ProbeCard = ({ probe }: { probe: Probe }) => { +import { ProbeUsageLink } from '../ProbeUsageLink'; +import { ProbeDisabledCapabilities } from './ProbeDisabledCapabilities'; +import { ProbeLabels } from './ProbeLabels'; +import { ProbeStatus } from './ProbeStatus'; + +export const ProbeCard = ({ probe }: { probe: ExtendedProbe }) => { const canEdit = useCanEditProbe(probe); - const [isFocused, setIsFocused] = useState(false); - const onlineTxt = probe.online ? 'Online' : 'Offline'; - const onlineIcon = probe.online ? 'heart' : 'heart-break'; - const color = probe.online ? 'green' : 'red'; - const probeTypeText = probe.public ? 'Public' : 'Private'; - const probeTypeIcon = probe.public ? 'cloud' : 'lock'; - const styles = useStyles2(getStyles); - const href = `${getRoute(ROUTES.EditProbe)}/${probe.id}`; + const probeEditHref = `${getRoute(ROUTES.EditProbe)}/${probe.id}`; const labelsString = labelsToString(probe.labels); + const styles = useStyles2(getStyles2); return ( - -
+ +
- setIsFocused(true)} onBlur={() => setIsFocused(false)}> - {probe.name} - -
-
- - - -
-
- {labelsString &&
Labels: {labelsString}
} -
Version: {probe.version}
+ + + {probe.name} + {probe.region &&  {`(${probe.region})`}} + +
+ + + +
Version: {probe.version}
+
+ + +
+ {labelsString && ( +
+ Labels:{' '} +
+ +
-
+ )} + +
-
- -
-
+ + + + {canEdit ? ( + <> + + Edit + + + ) : ( + + View + + )} +
); }; -const getStyles = (theme: GrafanaTheme2) => { +const getStyles2 = (theme: GrafanaTheme2) => { const containerName = `probeCard`; - const breakpoint = theme.breakpoints.values.sm; - const containerQuery = `@container ${containerName} (max-width: ${breakpoint}px)`; - const mediaQuery = `@supports not (container-type: inline-size) @media (max-width: ${breakpoint}px)`; return { card: css({ containerName, - containerType: `inline-size`, - marginBottom: theme.spacing(1), - - '&:hover button': { - opacity: 1, - }, - }), - cardContent: css({ - display: `grid`, - gridTemplateColumns: `auto 1fr auto`, - gridTemplateAreas: `"info gauge action"`, - - [containerQuery]: { - gridTemplateAreas: ` - "info action" - "gauge action" - `, - gridTemplateColumns: `1fr auto`, - }, - - [mediaQuery]: { - gridTemplateAreas: ` - "info action" - "gauge action" - `, - gridTemplateColumns: `1fr auto`, - }, - }), - info: css({ - display: `flex`, - gap: theme.spacing(0.5), - flexDirection: `column`, - gridArea: `info`, }), - badges: css({ - display: `flex`, + badgeContainer: css({ + display: `inline-flex`, gap: theme.spacing(0.5), - }), - meta: css({ - color: theme.colors.text.secondary, + flexWrap: `wrap`, }), gaugeContainer: css({ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gridArea: `gauge`, - - [containerQuery]: { - justifyContent: 'flex-start', - marginLeft: theme.spacing(-1), - marginTop: theme.spacing(1), - }, - - [mediaQuery]: { - justifyContent: 'flex-start', - marginLeft: theme.spacing(-1), - marginTop: theme.spacing(1), - }, }), - link: css({ - marginBottom: theme.spacing(1), + extendedTags: css({ + gridRowEnd: 'span 2', }), - buttonWrapper: css({ - alignItems: 'center', + extendedDescription: css({ + gridColumn: '1 / span 3', display: 'flex', - gap: theme.spacing(2), - gridArea: `action`, + justifyContent: 'space-between', }), - button: css({ - opacity: 0, - - [containerQuery]: { - opacity: 1, - }, - - [mediaQuery]: { - opacity: 1, - }, - }), - focussed: css({ - opacity: 1, + labelContainer: css({ + display: 'inline-flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), }), }; }; function labelsToString(labels: Label[]) { - return labels.map(({ name, value }) => `${name}:${value}`).join(', '); + return labels.map(({ name, value }) => `label_${name}: ${value}`).join(', '); } diff --git a/src/components/ProbeCard/ProbeDisabledCapabilities.tsx b/src/components/ProbeCard/ProbeDisabledCapabilities.tsx new file mode 100644 index 000000000..75ff86a83 --- /dev/null +++ b/src/components/ProbeCard/ProbeDisabledCapabilities.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; +import { Text } from '@grafana/ui'; + +import { type ExtendedProbe, FeatureName } from 'types'; +import { useFeatureFlag } from 'hooks/useFeatureFlag'; + +interface ProbeDisabledCapabilitiesProps { + probe: ExtendedProbe; +} +export function ProbeDisabledCapabilities({ probe }: ProbeDisabledCapabilitiesProps) { + // as we only want to show that a feature is disabled if the user can use the feature to start with + const scriptedFeature = useFeatureFlag(FeatureName.ScriptedChecks); + const browserFeature = useFeatureFlag(FeatureName.BrowserChecks); + + const browserChecksDisabled = probe.capabilities.disableBrowserChecks; + const scriptedChecksDisabled = probe.capabilities.disableScriptedChecks; + const noun = browserChecksDisabled && scriptedChecksDisabled ? 'types' : 'type'; + + const disabledChecks = useMemo(() => { + const disabledChecks = []; + if (scriptedFeature.isEnabled && scriptedChecksDisabled) { + disabledChecks.push('Scripted'); + } + + if (browserFeature.isEnabled && browserChecksDisabled) { + disabledChecks.push('Browser'); + } + + return disabledChecks.join(', '); + }, [browserChecksDisabled, browserFeature.isEnabled, scriptedChecksDisabled, scriptedFeature.isEnabled]); + + if (!disabledChecks) { + return
; + } + + return ( +
+ Unsupported check {noun}:  + {disabledChecks} +
+ ); +} diff --git a/src/components/ProbeCard/ProbeLabels.tsx b/src/components/ProbeCard/ProbeLabels.tsx new file mode 100644 index 000000000..48c2eb691 --- /dev/null +++ b/src/components/ProbeCard/ProbeLabels.tsx @@ -0,0 +1,27 @@ +import React, { Fragment } from 'react'; +import { Text } from '@grafana/ui'; + +import { Probe } from 'types'; + +const LABEL_PREFIX = 'label_'; + +interface ProbeLabelsProps { + labels: Probe['labels']; +} + +export function ProbeLabels({ labels }: ProbeLabelsProps) { + if (labels.length === 0) { + return null; + } + + return labels.map(({ name, value }, index) => { + return ( + + + {`${LABEL_PREFIX}${name}`}: {value} + {labels[index + 1] && ', '} + + + ); + }); +} diff --git a/src/components/ProbeCard/ProbeStatus.tsx b/src/components/ProbeCard/ProbeStatus.tsx new file mode 100644 index 000000000..2418de287 --- /dev/null +++ b/src/components/ProbeCard/ProbeStatus.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Tooltip, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import { Probe } from 'types'; + +export function ProbeStatus({ probe }: { probe: Probe }) { + const styles = useStyles2((theme) => getStyles(theme, probe)); + + return ( + + Probe {probe.name} is {probe.online ? 'online' : 'offline'} +
+ } + > +
+ + ); +} + +function getStyles(theme: GrafanaTheme2, probe: Probe) { + return { + container: css({ + display: 'inline-block', + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: probe.online ? theme.colors.success.text : theme.colors.error.text, + marginRight: theme.spacing(0.75), + }), + statusText: css({ + color: probe.online ? theme.colors.success.text : theme.colors.error.text, + }), + }; +} diff --git a/src/components/ProbeEditor/ProbeEditor.test.tsx b/src/components/ProbeEditor/ProbeEditor.test.tsx index aa96cab50..43b3b469f 100644 --- a/src/components/ProbeEditor/ProbeEditor.test.tsx +++ b/src/components/ProbeEditor/ProbeEditor.test.tsx @@ -3,9 +3,9 @@ import { config } from '@grafana/runtime'; import { screen, waitFor } from '@testing-library/react'; import { PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes'; import { render } from 'test/render'; -import { fillProbeForm, runTestAsViewer, UPDATED_VALUES } from 'test/utils'; +import { fillProbeForm, probeToExtendedProbe, runTestAsViewer, UPDATED_VALUES } from 'test/utils'; -import { FeatureName, ROUTES } from 'types'; +import { ExtendedProbe, FeatureName, Probe, ROUTES } from 'types'; import { getRoute } from 'components/Routing.utils'; import { TEMPLATE_PROBE } from 'page/NewProbe'; @@ -14,10 +14,10 @@ import { ProbeEditor } from './ProbeEditor'; const onSubmit = jest.fn(); const submitText = 'Save'; -const renderProbeEditor = async ({ probe = TEMPLATE_PROBE } = {}) => { +const renderProbeEditor = async ({ probe = TEMPLATE_PROBE }: { probe?: Probe | ExtendedProbe } = {}) => { const props = { onSubmit, - probe, + probe: probeToExtendedProbe(probe), submitText, }; diff --git a/src/components/ProbeEditor/ProbeEditor.tsx b/src/components/ProbeEditor/ProbeEditor.tsx index 16da29894..d3a31d034 100644 --- a/src/components/ProbeEditor/ProbeEditor.tsx +++ b/src/components/ProbeEditor/ProbeEditor.tsx @@ -6,7 +6,7 @@ import { css } from '@emotion/css'; import { zodResolver } from '@hookform/resolvers/zod'; import { ProbeSchema } from 'schemas/forms/ProbeSchema'; -import { FeatureName, Probe, ROUTES } from 'types'; +import { ExtendedProbe, FeatureName, Probe, ROUTES } from 'types'; import { useCanEditProbe } from 'hooks/useCanEditProbe'; import { FeatureFlag } from 'components/FeatureFlag'; import { HorizontalCheckboxField } from 'components/HorizonalCheckboxField'; @@ -19,7 +19,7 @@ type ProbeEditorProps = { actions?: ReactNode; errorInfo?: { title: string; message: string }; onSubmit: (formValues: Probe) => void; - probe: Probe; + probe: ExtendedProbe; submitText: string; supportingContent?: ReactNode; }; diff --git a/src/components/ProbeList/ProbeList.test.tsx b/src/components/ProbeList/ProbeList.test.tsx index 407bcc1d6..b0415dede 100644 --- a/src/components/ProbeList/ProbeList.test.tsx +++ b/src/components/ProbeList/ProbeList.test.tsx @@ -2,13 +2,16 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { DEFAULT_PROBES } from 'test/fixtures/probes'; import { render } from 'test/render'; +import { probeToExtendedProbe } from 'test/utils'; import { ProbeList } from './ProbeList'; const TITLE = `Default Probes`; it(`Toggles visibility of the probe cards`, async () => { - const { user } = render(); + const { user } = render( + probeToExtendedProbe(probe))} title={TITLE} /> + ); const cards = await screen.findAllByText(`Reachability`); const title = screen.getByText(TITLE); diff --git a/src/components/ProbeList/ProbeList.tsx b/src/components/ProbeList/ProbeList.tsx index 585a1d109..0ad0a5609 100644 --- a/src/components/ProbeList/ProbeList.tsx +++ b/src/components/ProbeList/ProbeList.tsx @@ -3,13 +3,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; -import { type Probe } from 'types'; +import { type ExtendedProbe } from 'types'; import { Collapse } from 'components/Collapse'; import { ProbeCard } from 'components/ProbeCard'; interface Props { [`data-testid`]?: string; - probes: Probe[]; + probes: ExtendedProbe[]; title: string; emptyText?: ReactNode; } diff --git a/src/components/ProbeStatus/ProbeStatus.test.tsx b/src/components/ProbeStatus/ProbeStatus.test.tsx index 11665b6aa..a6b396611 100644 --- a/src/components/ProbeStatus/ProbeStatus.test.tsx +++ b/src/components/ProbeStatus/ProbeStatus.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { OFFLINE_PROBE, ONLINE_PROBE, PRIVATE_PROBE } from 'test/fixtures/probes'; import { render } from 'test/render'; -import { runTestAsViewer } from 'test/utils'; +import { probeToExtendedProbe, runTestAsViewer } from 'test/utils'; import { formatDate } from 'utils'; @@ -11,13 +11,13 @@ import { ProbeStatus } from './ProbeStatus'; it(`hides the reset button when the user is a viewer`, async () => { runTestAsViewer(); // We need to wait for contexts to finish loading to avoid issue with act - await waitFor(() => render()); + await waitFor(() => render()); const resetButton = await getResetButton(true); expect(resetButton).not.toBeInTheDocument(); }); it(`shows the reset probe access token when the user is an editor`, async () => { - render(); + render(); const resetButton = await getResetButton(); expect(resetButton).toBeInTheDocument(); }); @@ -29,19 +29,19 @@ describe(`Last on/offline display`, () => { onlineChange: OFFLINE_PROBE.created!, }; - render(); + render(); expect(await screen.findByText('Last online:')).toBeInTheDocument(); expect(await screen.findByText('Never')).toBeInTheDocument(); }); it(`displays last online correctly`, async () => { - render(); + render(); expect(await screen.findByText('Last online:')).toBeInTheDocument(); expect(await screen.findByText(formatDate(OFFLINE_PROBE.onlineChange * 1000))).toBeInTheDocument(); }); it(`displays last offline correctly`, async () => { - render(); + render(); expect(await screen.findByText('Last offline:')).toBeInTheDocument(); expect(await screen.findByText(formatDate(ONLINE_PROBE.onlineChange * 1000))).toBeInTheDocument(); }); @@ -54,13 +54,13 @@ describe(`Last modified display`, () => { modified: OFFLINE_PROBE.created, }; - render(); + render(); expect(await screen.findByText('Last modified:')).toBeInTheDocument(); expect(await screen.findByText('Never')).toBeInTheDocument(); }); it(`displays last modified correctly`, async () => { - render(); + render(); expect(await screen.findByText('Last modified:')).toBeInTheDocument(); expect(await screen.findByText(formatDate(ONLINE_PROBE.modified! * 1000))).toBeInTheDocument(); }); diff --git a/src/components/ProbeStatus/ProbeStatus.tsx b/src/components/ProbeStatus/ProbeStatus.tsx index fe82382c2..2d72f7b15 100644 --- a/src/components/ProbeStatus/ProbeStatus.tsx +++ b/src/components/ProbeStatus/ProbeStatus.tsx @@ -3,14 +3,16 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Badge, BadgeColor, Button, ConfirmModal, Container, IconName, Legend, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; -import { type Probe } from 'types'; +import { type ExtendedProbe } from 'types'; import { formatDate } from 'utils'; import { useResetProbeToken } from 'data/useProbes'; import { useCanEditProbe } from 'hooks/useCanEditProbe'; import { SuccessRateGaugeProbe } from 'components/Gauges'; -interface Props { - probe: Probe; +import { ProbeUsageLink } from '../ProbeUsageLink'; + +interface ProbeStatusProps { + probe: ExtendedProbe; onReset: (token: string) => void; } @@ -20,7 +22,7 @@ interface BadgeStatus { icon: IconName; } -export const ProbeStatus = ({ probe, onReset }: Props) => { +export const ProbeStatus = ({ probe, onReset }: ProbeStatusProps) => { const [showResetModal, setShowResetModal] = useState(false); const canEdit = useCanEditProbe(probe); @@ -73,6 +75,7 @@ export const ProbeStatus = ({ probe, onReset }: Props) => { {probe.modified && ( )} +
); diff --git a/src/components/ProbeUsageLink.tsx b/src/components/ProbeUsageLink.tsx new file mode 100644 index 000000000..26cd9bc78 --- /dev/null +++ b/src/components/ProbeUsageLink.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { ThemeTypographyVariantTypes } from '@grafana/data'; +import { TextLink } from '@grafana/ui'; + +import { ExtendedProbe, ROUTES } from 'types'; + +import { DataTestIds } from '../test/dataTestIds'; +import { getRoute } from './Routing.utils'; + +interface ProbeUsageLinkProps { + probe: ExtendedProbe; + variant?: keyof Omit; + showWhenUnused?: boolean; + className?: string; +} + +export function ProbeUsageLink({ probe, className, variant, showWhenUnused = false }: ProbeUsageLinkProps) { + const hasChecks = probe.checks.length > 0; + const checksCount = hasChecks && probe.checks.length > 0 ? probe.checks.length : 0; + const checksHref = `${getRoute(ROUTES.Checks)}?probes=${probe.name}`; + const noun = hasChecks && checksCount > 1 ? 'checks' : 'check'; + + if (!hasChecks && !showWhenUnused) { + return null; + } + + return ( + + {`Used in ${checksCount} ${noun}`} + + ); +} diff --git a/src/data/useChecks.ts b/src/data/useChecks.ts index d80ee5da0..0b5068c18 100644 --- a/src/data/useChecks.ts +++ b/src/data/useChecks.ts @@ -153,7 +153,7 @@ export function useDeleteCheck({ eventInfo, onError, onSuccess }: MutationProps< }); } -type ExtendedBulkUpdateCheckResult = BulkUpdateCheckResult & { +export type ExtendedBulkUpdateCheckResult = BulkUpdateCheckResult & { checks: Check[]; }; diff --git a/src/data/useLogs.ts b/src/data/useLogs.ts index 28cb2a5ec..d41497ced 100644 --- a/src/data/useLogs.ts +++ b/src/data/useLogs.ts @@ -14,8 +14,7 @@ export function useLogs() { return useMutation({ mutationFn: async ({ expr, range }: Args) => { try { - const res = await smDS.queryLogs(expr, range); - return res; + return await smDS.queryLogs(expr, range); } catch (error) { throw error; } diff --git a/src/data/useProbes.ts b/src/data/useProbes.ts index 1ae998100..6a21330a7 100644 --- a/src/data/useProbes.ts +++ b/src/data/useProbes.ts @@ -1,12 +1,14 @@ +import { useMemo } from 'react'; import { type QueryKey, useMutation, UseMutationResult, useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { isFetchError } from '@grafana/runtime'; import { type MutationProps } from 'data/types'; -import { type Probe } from 'types'; +import { ExtendedProbe, type Probe } from 'types'; import { FaroEvent } from 'faro'; import { SMDataSource } from 'datasource/DataSource'; import type { AddProbeResult, + DeleteProbeError, DeleteProbeResult, ResetProbeTokenResult, UpdateProbeResult, @@ -14,6 +16,8 @@ import type { import { queryClient } from 'data/queryClient'; import { useSMDS } from 'hooks/useSMDS'; +import { useChecks } from './useChecks'; + export const queryKeys: Record<'list', QueryKey> = { list: ['probes'], }; @@ -31,6 +35,42 @@ export function useProbes() { return useQuery(probesQuery(smDS)); } +export function useExtendedProbes(): [ExtendedProbe[], boolean] { + const { data: probes = [], isLoading: isLoadingProbes } = useProbes(); + const { data: checks = [], isLoading: isLoadingChecks } = useChecks(); + const isLoading = isLoadingProbes || isLoadingChecks; + + return useMemo<[ExtendedProbe[], boolean]>(() => { + if (isLoadingProbes || isLoadingChecks) { + return [[], isLoading]; + } + + const extendedProbes = probes.map((probe) => { + return checks.reduce( + (acc, check) => { + if (probe.id && check.id && check.probes.includes(probe.id)) { + acc.checks.push(check.id); + } + + return acc; + }, + { ...probe, checks: [] } + ); + }); + + return [extendedProbes, isLoading]; + }, [isLoadingProbes, isLoadingChecks, probes, isLoading, checks]); +} + +export function useExtendedProbe(id: number): [ExtendedProbe | undefined, boolean] { + const [probes, isLoading] = useExtendedProbes(); + const probe = probes.find((probe) => probe.id === id); + + return useMemo(() => { + return [probe, isLoading]; + }, [probe, isLoading]); +} + export function useSuspenseProbes() { const smDS = useSMDS(); @@ -54,12 +94,10 @@ export function useCreateProbe({ eventInfo, onError, onSuccess }: MutationProps< return useMutation({ mutationFn: async (probe: Probe) => { try { - const res = await smDS.addProbe({ + return await smDS.addProbe({ ...probe, public: false, }); - - return res; } catch (error) { throw handleAddProbeError(error); } @@ -122,7 +160,7 @@ export function useDeleteProbe({ eventInfo, onError, onSuccess }: MutationProps< const smDS = useSMDS(); const eventType = FaroEvent.DELETE_PROBE; - return useMutation({ + return useMutation({ mutationFn: (probe: Probe) => smDS.deleteProbe(probe.id!).then((res) => ({ ...res, diff --git a/src/datasource/DataSource.ts b/src/datasource/DataSource.ts index 216cbbb81..39faf0c07 100644 --- a/src/datasource/DataSource.ts +++ b/src/datasource/DataSource.ts @@ -8,7 +8,7 @@ import { ScopedVars, TimeRange, } from '@grafana/data'; -import { getBackendSrv, getTemplateSrv } from '@grafana/runtime'; +import { BackendSrvRequest, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { isArray } from 'lodash'; import { firstValueFrom } from 'rxjs'; @@ -16,9 +16,8 @@ import { Check, Probe, ThresholdSettings } from '../types'; import { AccessTokenResponse, AddCheckResult, - AddProbeResult, + type AddProbeResult, AdHocCheckResponse, - BulkUpdateCheckResult, CheckInfoResult, DeleteCheckResult, DeleteProbeResult, @@ -27,14 +26,16 @@ import { ListTenantLimitsResponse, ListTenantSettingsResult, LogsQueryResponse, - ResetProbeTokenResult, + type ResetProbeTokenResult, + TenantResponse, UpdateCheckResult, - UpdateProbeResult, - UpdateTenantSettingsResult, + type UpdateProbeResult, + type UpdateTenantSettingsResult, } from './responses.types'; import { QueryType, SMOptions, SMQuery } from './types'; import { findLinkedDatasource, getRandomProbes, queryLogs } from 'utils'; +import { ExtendedBulkUpdateCheckResult } from '../data/useChecks'; import { parseTracerouteLogs } from './traceroute-utils'; export class SMDataSource extends DataSourceApi { @@ -42,6 +43,22 @@ export class SMDataSource extends DataSourceApi { super(instanceSettings); } + async fetchAPI(url: BackendSrvRequest['url'], options?: Omit) { + const response = await firstValueFrom( + getBackendSrv().fetch({ + method: options?.method ?? 'GET', + url, + ...options, + }) + ).catch((error: unknown) => { + // We could log the error here + + throw error; + }); + + return response?.data as T; + } + interpolateVariablesInQueries(queries: SMQuery[], scopedVars: {} | ScopedVars): SMQuery[] { const interpolated: SMQuery[] = []; const templateSrv = getTemplateSrv(); @@ -193,39 +210,27 @@ export class SMDataSource extends DataSourceApi { } async getCheckInfo() { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `${this.instanceSettings.url}/sm/checks/info`, - }) - ).then((res) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/checks/info`); } - queryLogs(expr: string, range: TimeRange) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `/api/ds/query`, - data: { - queries: [ - { - refId: 'A', - expr, - queryType: 'range', - datasource: this.instanceSettings.jsonData.logs, - intervalMs: 2000, - maxDataPoints: 1779, - }, - ], - from: String(range.from.unix() * 1000), - to: String(range.to.unix() * 1000), - }, - }) - ).then((res) => { - return res.data.results.A.frames; - }); + async queryLogs(expr: string, range: TimeRange) { + return this.fetchAPI(`/api/ds/query`, { + method: 'POST', + data: { + queries: [ + { + refId: 'A', + expr, + queryType: 'range', + datasource: this.instanceSettings.jsonData.logs, + intervalMs: 2000, + maxDataPoints: 1779, + }, + ], + from: String(range.from.unix() * 1000), + to: String(range.to.unix() * 1000), + }, + }).then((data) => data.results.A.frames); } //-------------------------------------------------------------------------------- @@ -233,61 +238,32 @@ export class SMDataSource extends DataSourceApi { //-------------------------------------------------------------------------------- async listProbes() { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `${this.instanceSettings.url}/sm/probe/list`, - }) - ).then((res) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/probe/list`); } async addProbe(probe: Probe) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/probe/add`, - data: probe, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/probe/add`, { + method: 'POST', + data: probe, }); } async updateProbe(probe: Probe) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/probe/update`, - data: probe, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/probe/update`, { + method: 'POST', + data: probe, }); } - async deleteProbe(id: number) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'DELETE', - url: `${this.instanceSettings.url}/sm/probe/delete/${id}`, - }) - ).then((res) => { - return res.data; + async resetProbeToken(probe: Probe) { + return this.fetchAPI(`${this.instanceSettings.url}/sm/probe/update?reset-token=true`, { + method: 'POST', + data: probe, }); } - async resetProbeToken(probe: Probe) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/probe/update?reset-token=true`, - data: probe, - }) - ).then((res) => { - return res.data; - }); + async deleteProbe(id: number) { + return this.fetchAPI(`${this.instanceSettings.url}/sm/probe/delete/${id}`, { method: 'DELETE' }); } //-------------------------------------------------------------------------------- @@ -295,144 +271,76 @@ export class SMDataSource extends DataSourceApi { //-------------------------------------------------------------------------------- async listChecks() { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `${this.instanceSettings.url}/sm/check/list`, - }) - ).then((res) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/list`); } async getCheck(checkId: number) { - return firstValueFrom( - getBackendSrv().fetch({ - method: `GET`, - url: `${this.instanceSettings.url}/sm/check/${checkId}`, - }) - ).then((res) => res.data); + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/${checkId}`); } async testCheck(check: Check) { const payload = getTestPayload(check); - - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/check/adhoc`, - data: payload, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/adhoc`, { + method: 'POST', + data: payload, }); } async addCheck(check: Check) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/check/add`, - data: check, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/add`, { + method: 'POST', + data: check, }); } async deleteCheck(id: number) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'DELETE', - url: `${this.instanceSettings.url}/sm/check/delete/${id}`, - }) - ).then((res) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/delete/${id}`, { method: 'DELETE' }); } async updateCheck(check: Check) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/check/update`, - data: check, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/update`, { + method: 'POST', + data: check, }); } async bulkUpdateChecks(checks: Check[]) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/check/update/bulk`, - data: checks, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/check/update/bulk`, { + method: 'POST', + data: checks, }); } - async getTenant(): Promise { - return getBackendSrv() - .fetch({ method: 'GET', url: `${this.instanceSettings.url}/sm/tenant` }) - .toPromise() - .then((res: any) => { - return res.data; - }); + async getTenant() { + return this.fetchAPI(`${this.instanceSettings.url}/sm/tenant`); } async getTenantLimits() { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `${this.instanceSettings.url}/sm/tenant/limits`, - }) - ).then((res) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/tenant/limits`); } async getTenantSettings() { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `${this.instanceSettings.url}/sm/tenant/settings`, - }) - ).then((res) => res.data); + return this.fetchAPI(`${this.instanceSettings.url}/sm/tenant/settings`); } async updateTenantSettings(settings: { thresholds: ThresholdSettings }) { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/tenant/settings/update`, - data: { - ...settings, - }, - }) - ).then((res) => { - return res.data; + return this.fetchAPI(`${this.instanceSettings.url}/sm/tenant/settings/update`, { + method: 'POST', + data: { + ...settings, + }, }); } async disableTenant(): Promise { const tenant = await this.getTenant(); - return getBackendSrv() - .fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/tenant/update`, - data: { - ...tenant, - status: 1, - }, - }) - .toPromise() - .then((res: any) => { - return res.data; - }); + return this.fetchAPI(`${this.instanceSettings.url}/sm/tenant/update`, { + method: 'POST', + data: { + ...tenant, + status: 1, + }, + }); } //-------------------------------------------------------------------------------- @@ -487,13 +395,10 @@ export class SMDataSource extends DataSourceApi { } async createApiToken(): Promise { - return firstValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: `${this.instanceSettings.url}/sm/token/create`, - data: {}, - }) - ).then((res) => res.data.token); + return this.fetchAPI(`${this.instanceSettings.url}/sm/token/create`, { + method: 'POST', + data: {}, + }).then((data) => data.token); } //-------------------------------------------------------------------------------- diff --git a/src/datasource/responses.types.ts b/src/datasource/responses.types.ts index cc6dd1b58..86b5ad5b5 100644 --- a/src/datasource/responses.types.ts +++ b/src/datasource/responses.types.ts @@ -10,11 +10,18 @@ export type AddProbeResult = { token: string; }; -export type DeleteProbeResult = { +export type DeleteProbeSuccess = { msg: string; probeId: Probe['id']; }; +export type DeleteProbeError = { + err: string; + msg: string; +}; + +export type DeleteProbeResult = DeleteProbeSuccess | DeleteProbeError; + export type UpdateProbeResult = { probe: Probe; }; diff --git a/src/hooks/useCheckFilters.ts b/src/hooks/useCheckFilters.ts index 341f06069..cfb5b7905 100644 --- a/src/hooks/useCheckFilters.ts +++ b/src/hooks/useCheckFilters.ts @@ -68,10 +68,10 @@ export function useCheckFilters() { encode: (value) => value.map((probe) => probe.label).join(','), decode: (value) => { const labels = value.split(','); - const probesValues = probes + + return probes .filter((probe) => labels.includes(probe.name)) .map((probe) => ({ label: probe.name, value: probe.id } as SelectableValue)); - return probesValues; }, }), }; diff --git a/src/page/EditProbe/EditProbe.tsx b/src/page/EditProbe/EditProbe.tsx index 8b90bea38..a200e3348 100644 --- a/src/page/EditProbe/EditProbe.tsx +++ b/src/page/EditProbe/EditProbe.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { PluginPage } from '@grafana/runtime'; -import { Button, ConfirmModal } from '@grafana/ui'; -import { type Probe, type ProbePageParams, ROUTES } from 'types'; -import { useDeleteProbe, useSuspenseProbe, useUpdateProbe } from 'data/useProbes'; +import { ExtendedProbe, type Probe, type ProbePageParams, ROUTES } from 'types'; +import { useExtendedProbe, useUpdateProbe } from 'data/useProbes'; import { useCanEditProbe } from 'hooks/useCanEditProbe'; import { useNavigation } from 'hooks/useNavigation'; +import { DeleteProbeButton } from 'components/DeleteProbeButton'; import { ProbeEditor } from 'components/ProbeEditor'; import { ProbeStatus } from 'components/ProbeStatus'; import { ProbeTokenModal } from 'components/ProbeTokenModal'; @@ -15,7 +15,7 @@ import { QueryErrorBoundary } from 'components/QueryErrorBoundary'; import { getErrorInfo, getTitle } from './EditProbe.utils'; export const EditProbe = () => { - const [probe, setProbe] = useState(); + const [probe, setProbe] = useState(); const canEdit = useCanEditProbe(probe); return ( @@ -27,9 +27,9 @@ export const EditProbe = () => { ); }; -const EditProbeFetch = ({ onProbeFetch }: { onProbeFetch: (probe: Probe) => void }) => { +const EditProbeFetch = ({ onProbeFetch }: { onProbeFetch: (probe: ExtendedProbe) => void }) => { const { id } = useParams(); - const { data: probe, isLoading } = useSuspenseProbe(Number(id)); + const [probe, isLoading] = useExtendedProbe(Number(id)); const navigate = useNavigation(); useEffect(() => { @@ -49,49 +49,39 @@ const EditProbeFetch = ({ onProbeFetch }: { onProbeFetch: (probe: Probe) => void return ; }; -const EditProbeContent = ({ probe }: { probe: Probe }) => { +const EditProbeContent = ({ probe }: { probe: ExtendedProbe }) => { const navigate = useNavigation(); const canEdit = useCanEditProbe(probe); const [showTokenModal, setShowTokenModal] = useState(false); const [probeToken, setProbeToken] = useState(``); - const [showDeleteModal, setShowDeleteModal] = useState(false); const onUpdateSuccess = useCallback(() => { navigate(ROUTES.Probes); }, [navigate]); - const onDeleteSuccess = useCallback(() => { - setShowDeleteModal(false); - navigate(ROUTES.Probes); - }, [navigate]); - const { mutate: onUpdate, error: updateError } = useUpdateProbe({ onSuccess: onUpdateSuccess }); - const { mutate: onDelete, error: deleteError } = useDeleteProbe({ onSuccess: onDeleteSuccess }); const handleSubmit = useCallback( (formValues: Probe) => { + const { checks, ...probeEntity } = probe; + return onUpdate({ - ...probe, + ...probeEntity, ...formValues, }); }, [onUpdate, probe] ); - useEffect(() => { - if (deleteError) { - setShowDeleteModal(false); - } - }, [deleteError]); + const errorInfo = getErrorInfo(updateError); + + const handleOnDeleteSuccess = useCallback(() => { + navigate(ROUTES.Probes); + }, [navigate]); const actions = useMemo( - () => - canEdit ? ( - - ) : null, - [canEdit] + () => (canEdit ? : null), + [canEdit, handleOnDeleteSuccess, probe] ); const onReset = useCallback((token: string) => { @@ -103,7 +93,7 @@ const EditProbeContent = ({ probe }: { probe: Probe }) => { <> { onDismiss={() => setShowTokenModal(false)} token={probeToken} /> - onDelete(probe)} - onDismiss={() => setShowDeleteModal(false)} - /> ); }; diff --git a/src/page/EditProbe/EditProbe.utils.tsx b/src/page/EditProbe/EditProbe.utils.tsx index dabbf379d..00f00b471 100644 --- a/src/page/EditProbe/EditProbe.utils.tsx +++ b/src/page/EditProbe/EditProbe.utils.tsx @@ -9,7 +9,7 @@ export function getTitle(probe?: Probe, canEdit?: boolean) { type ErrorInfo = Error | null; -export function getErrorInfo(updateError: ErrorInfo, deleteError: ErrorInfo) { +export function getErrorInfo(updateError: ErrorInfo) { if (updateError) { return { title: 'Failed to update probe', @@ -17,16 +17,5 @@ export function getErrorInfo(updateError: ErrorInfo, deleteError: ErrorInfo) { }; } - if (deleteError) { - const message = deleteError.message.includes('delete not allowed') - ? 'You may have checks that are still using this probe.' - : deleteError.message; - - return { - title: 'Failed to delete probe', - message, - }; - } - return undefined; } diff --git a/src/page/NewProbe/NewProbe.tsx b/src/page/NewProbe/NewProbe.tsx index f7a827388..b6a643890 100644 --- a/src/page/NewProbe/NewProbe.tsx +++ b/src/page/NewProbe/NewProbe.tsx @@ -3,7 +3,7 @@ import { PluginPage } from '@grafana/runtime'; import { Alert, useTheme2 } from '@grafana/ui'; import { css } from '@emotion/css'; -import { type Probe, ROUTES } from 'types'; +import { ExtendedProbe, type Probe, ROUTES } from 'types'; import { type AddProbeResult } from 'datasource/responses.types'; import { useCreateProbe } from 'data/useProbes'; import { useNavigation } from 'hooks/useNavigation'; @@ -12,7 +12,7 @@ import { DocsLink } from 'components/DocsLink'; import { ProbeEditor } from 'components/ProbeEditor'; import { ProbeTokenModal } from 'components/ProbeTokenModal'; -export const TEMPLATE_PROBE: Probe = { +export const TEMPLATE_PROBE: ExtendedProbe = { name: '', public: false, latitude: 0.0, @@ -27,6 +27,7 @@ export const TEMPLATE_PROBE: Probe = { disableScriptedChecks: false, disableBrowserChecks: false, }, + checks: [], }; export const NewProbe = () => { diff --git a/src/page/Probes/Probes.test.tsx b/src/page/Probes/Probes.test.tsx index 1ab6ce249..8348c2080 100644 --- a/src/page/Probes/Probes.test.tsx +++ b/src/page/Probes/Probes.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { screen, within } from '@testing-library/react'; import { DataTestIds } from 'test/dataTestIds'; -import { DEFAULT_PROBES, PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes'; +import { PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes'; import { render } from 'test/render'; import { ROUTES } from 'types'; @@ -13,18 +13,6 @@ const renderProbeList = () => { return render(); }; -it('renders offline probes', async () => { - renderProbeList(); - const onlineStatus = await screen.findAllByText('Offline'); - expect(onlineStatus.length).toBe(DEFAULT_PROBES.filter((p) => !p.online).length); -}); - -it('renders online probes', async () => { - renderProbeList(); - const onlineStatus = await screen.findAllByText('Online'); - expect(onlineStatus.length).toBe(DEFAULT_PROBES.filter((p) => p.online).length); -}); - it(`renders private probes in the correct list`, async () => { renderProbeList(); const privateProbesList = await screen.findByTestId(DataTestIds.PRIVATE_PROBES_LIST); diff --git a/src/page/Probes/Probes.tsx b/src/page/Probes/Probes.tsx index 05c62af5d..e83cd643d 100644 --- a/src/page/Probes/Probes.tsx +++ b/src/page/Probes/Probes.tsx @@ -4,8 +4,8 @@ import { LinkButton, useTheme2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { DataTestIds } from 'test/dataTestIds'; -import { type Probe, ROUTES } from 'types'; -import { useProbes } from 'data/useProbes'; +import { ExtendedProbe, ROUTES } from 'types'; +import { useExtendedProbes } from 'data/useProbes'; import { useCanWriteSM } from 'hooks/useDSPermission'; import { CenteredSpinner } from 'components/CenteredSpinner'; import { DocsLink } from 'components/DocsLink'; @@ -42,21 +42,21 @@ const Actions = () => { }; const ProbesContent = () => { - const { data: probes = [], isLoading } = useProbes(); + const [extendedProbes, isLoading] = useExtendedProbes(); if (isLoading) { return ; } const initial: { - publicProbes: Probe[]; - privateProbes: Probe[]; + publicProbes: ExtendedProbe[]; + privateProbes: ExtendedProbe[]; } = { publicProbes: [], privateProbes: [], }; - const { publicProbes, privateProbes } = probes + const { publicProbes, privateProbes } = extendedProbes .sort((probeA, probeB) => probeA.name.localeCompare(probeB.name)) .filter((probe) => Boolean(probe.id)) .reduce((acc, probe) => { diff --git a/src/test/dataTestIds.ts b/src/test/dataTestIds.ts index 2f8f7fd44..0925fa7e0 100644 --- a/src/test/dataTestIds.ts +++ b/src/test/dataTestIds.ts @@ -10,4 +10,5 @@ export enum DataTestIds { ACTIONS_BAR = 'actions-bar', PAGE_READY = 'page-ready', PAGE_NOT_READY = 'page-not-ready', + PROBE_USAGE_LINK = 'probe-usage-link', } diff --git a/src/test/utils.ts b/src/test/utils.ts index 5c1f2f958..45a8e24ce 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -8,7 +8,7 @@ import { VIEWER_DEFAULT_DATASOURCE_ACCESS_CONTROL, } from 'test/fixtures/datasources'; -import { type Probe } from 'types'; +import { ExtendedProbe, type Probe } from 'types'; import { apiRoute } from './handlers'; import { server } from './server'; @@ -201,3 +201,8 @@ export const selectOption = async (user: UserEvent, options: SelectOptions, cont await user.click(option); }; + +export const probeToExtendedProbe = (probe: Probe, usedByChecks: number[] = []): ExtendedProbe => ({ + ...probe, + checks: usedByChecks, +}); diff --git a/src/types.ts b/src/types.ts index d5d5f0df5..782fffe86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,6 +119,9 @@ export interface Probe extends ExistingObject { capabilities: ProbeCapabilities; } +// Used to extend the Probe object with additional properties (see Probes.tsx component) +export type ExtendedProbe = Probe & { checks: number[] }; + interface ProbeCapabilities { disableScriptedChecks: boolean; disableBrowserChecks: boolean; diff --git a/src/utils.types.ts b/src/utils.types.ts index 51a2fcbb4..c4075b424 100644 --- a/src/utils.types.ts +++ b/src/utils.types.ts @@ -54,11 +54,7 @@ export function isPingCheck(check: Partial): check is PingCheck { } export function isScriptedCheck(check: Partial): check is ScriptedCheck { - if (Object.hasOwnProperty.call(check.settings, 'scripted')) { - return true; - } - - return false; + return CheckType.Scripted in (check.settings ?? {}); } export function isTCPCheck(check: Partial): check is TCPCheck { diff --git a/yarn.lock b/yarn.lock index ea0f088df..c77e2570e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10450,7 +10450,16 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10533,7 +10542,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11521,7 +11537,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11539,6 +11555,15 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"