diff --git a/.changeset/rare-llamas-occur.md b/.changeset/rare-llamas-occur.md new file mode 100644 index 000000000..764f61a21 --- /dev/null +++ b/.changeset/rare-llamas-occur.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-supernova": minor +--- + +Refactored fetch with ReactQuery and minor UI improvements diff --git a/apps/supernova/src/AppContent.jsx b/apps/supernova/src/AppContent.jsx index e80afcc8d..9cdcc99f6 100644 --- a/apps/supernova/src/AppContent.jsx +++ b/apps/supernova/src/AppContent.jsx @@ -4,82 +4,31 @@ */ import React from "react" -import { useActions, Messages } from "@cloudoperators/juno-messages-provider" -import { Container, Stack, Spinner } from "@cloudoperators/juno-ui-components" -import { useAlertsUpdatedAt, useAlertsTotalCounts, useGlobalsActiveSelectedTab } from "./components/StoreProvider" -import AlertsList from "./components/alerts/AlertsList" +import { Messages, MessagesProvider } from "@cloudoperators/juno-messages-provider" +import { Container } from "@cloudoperators/juno-ui-components" +import { useGlobalsActiveSelectedTab } from "./components/StoreProvider" import RegionsList from "./components/regions/RegionsList" -import StatusBar from "./components/status/StatusBar" -import Filters from "./components/filters/Filters" -import { parseError } from "./helpers" import AlertDetail from "./components/alerts/AlertDetail" -import PredefinedFilters from "./components/filters/PredefinedFilters" import SilencesList from "./components/silences/SilencesList" - -import { useBoundQuery } from "./hooks/useBoundQuery" +import AlertsTab from "./components/alerts/AlertsTab" const AppContent = () => { - const { addMessage } = useActions() - - // alerts - const totalCounts = useAlertsTotalCounts() - const updatedAt = useAlertsUpdatedAt() - const activeSelectedTab = useGlobalsActiveSelectedTab() - const { error: alertsError, isLoading: isAlertsLoading } = useBoundQuery("alerts") - const { error: silencesError, isLoading: isSilencesLoading } = useBoundQuery("silences") - // since the API call is done in a web worker and not logging aware, we need to show the error just in case the user is logged in - if (silencesError) { - addMessage({ - variant: "error", - text: parseError(alertsError), - }) - } - - // since the API call is done in a web worker and not logging aware, we need to show the error just in case the user is logged in - if (alertsError) { - addMessage({ - variant: "error", - text: parseError(silencesError), - }) - } - return ( {activeSelectedTab === "alerts" && ( <> - + + + - {isAlertsLoading ? ( - - Loading - - - ) : ( - <> - - - - - - )} - - )} - {activeSelectedTab === "silences" && ( - <> - {isSilencesLoading ? ( - - Loading - - - ) : ( - - )} + )} + {activeSelectedTab === "silences" && } ) } diff --git a/apps/supernova/src/lib/queries/alertsQueries.js b/apps/supernova/src/api/alerts.js similarity index 96% rename from apps/supernova/src/lib/queries/alertsQueries.js rename to apps/supernova/src/api/alerts.js index 9190d074b..90b6b629f 100644 --- a/apps/supernova/src/lib/queries/alertsQueries.js +++ b/apps/supernova/src/api/alerts.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { sortAlerts, countAlerts } from "../utils" +import { sortAlerts, countAlerts } from "../lib/utils" let compareAlertString export const fetchAlerts = async (endpoint) => { diff --git a/apps/supernova/src/api/apiService.js b/apps/supernova/src/api/apiService.js deleted file mode 100644 index aeb0bdb6d..000000000 --- a/apps/supernova/src/api/apiService.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * This module implements a service to retrieve information from an API - * @module apiService - */ - -// default value for watch interval in ms -const DEFAULT_INTERVAL = 300000 - -/** - * This function implements the actual service. - * @param {object} initialConfig - */ -function ApiService(initialConfig) { - // default config - let config = { - serviceName: null, - initialFetch: true, // Set this to false to disable this service from automatically running. - fetchFn: null, // The promise function that the service will use to request data - watch: true, // if true runs the fetchFn periodically with an interval defined in watchInterval - watchInterval: DEFAULT_INTERVAL, // 5 min - onFetchStart: null, - onFetchEnd: null, - onFetchError: null, - debug: false, - } - - let initialFetchPerformed = false - - // get the allowed config keys from default config - const allowedOptions = Object.keys(config) - // variable to hold the watch timer created by setInterval - let watchTimer - - // This function performs the request to get the target data - const update = () => { - if (config.fetchFn) { - // call onFetchStart if defined - // This is useful to inform the listener that a new fetch is starting - if (config.onFetchStart) config.onFetchStart() - if (config?.debug) console.info(`ApiService::${config.serviceName || ""}: start fetch`) - initialFetchPerformed = true - return config - .fetchFn() - .then(() => { - if (config.onFetchEnd) config.onFetchEnd() - }) - .catch((error) => { - console.warn("ApiService::%s:%s", config.serviceName, error) - if (config.onFetchError) config.onFetchError(error) - }) - } else { - if (config?.debug) console.warn(`ApiService::${config.serviceName || ""}: missing fetch function`) - return - } - } - - // update watcher if config has changed - const updateWatcher = (oldConfig) => { - // do nothing if watch and watchInterval are the same - if (initialFetchPerformed && oldConfig.watch === config.watch && oldConfig.watchInterval === config.watchInterval) - return - - // delete last watcher - clearInterval(watchTimer) - - // create a new watcher which calls the update method - if (config.watch) { - watchTimer = setInterval(update, config.watchInterval || DEFAULT_INTERVAL) - } - } - - // this function is public and used to update the config - this.configure = (newOptions) => { - const oldConfig = { ...config } - // update apiService config - config = { ...config, ...newOptions } - - // check for allowed keys - Object.keys(config).forEach((key) => allowedOptions.indexOf(key) < 0 && delete config[key]) - - if (config?.debug) console.debug("ApiService::%s: new config: %s", config.serviceName, config) - - // update watcher will check the config relevant attribute changed to update the watcher - updateWatcher(oldConfig) - if (config.initialFetch && !initialFetchPerformed) update() - } - - // make it possible to update explicitly, not by a watcher! - this.fetch = update - - // set the config initially - this.configure(initialConfig) -} - -export default ApiService diff --git a/apps/supernova/src/api/client.js b/apps/supernova/src/api/client.js deleted file mode 100644 index fd014a438..000000000 --- a/apps/supernova/src/api/client.js +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -class HTTPError extends Error { - constructor(code, message) { - super(message || code) - this.name = "HTTPError" - this.statusCode = code - } -} - -// Check response status -const checkStatus = (response) => { - if (response.status < 400) { - return response - } else { - return response.text().then((message) => { - var error = new HTTPError(response.status, message || response.statusText) - error.statusCode = response.status - error.httperror = true - return Promise.reject(error) - }) - } -} - -const DEFAULT_HEADERS = { - "Content-Type": "application/json", - Accept: "application/json", -} - -const request = (url, options = {}) => { - const requestOptions = { headers: DEFAULT_HEADERS, ...options } - - return fetch(url, requestOptions) - .then(checkStatus) - .then((response) => { - const contentType = response.headers.get("Content-Type") - if (contentType && contentType.includes("application/json")) { - return response.json() - } else { - return response.text() - } - }) -} - -export const head = (url, options = {}) => request(url, { method: "HEAD", ...options }) -export const get = (url, options = {}) => request(url, { method: "GET", ...options }) -export const post = (url, options = {}) => request(url, { method: "POST", ...options }) -export const put = (url, options = {}) => request(url, { method: "PUT", ...options }) -export const patch = (url, options = {}) => request(url, { method: "PATCH", ...options }) -export const del = (url, options = {}) => request(url, { method: "DELETE", ...options }) -export const copy = (url, options = {}) => request(url, { method: "COPY", ...options }) diff --git a/apps/supernova/src/api/mutationFunctions.js b/apps/supernova/src/api/mutationFunctions.js new file mode 100644 index 000000000..27128d277 --- /dev/null +++ b/apps/supernova/src/api/mutationFunctions.js @@ -0,0 +1,6 @@ +import { deleteSilences, createSilences } from "./silences" + +export const MUTATION_FUNCTIONS = { + deleteSilences: deleteSilences, + createSilences: createSilences, +} diff --git a/apps/supernova/src/lib/queries/queryFunctions.js b/apps/supernova/src/api/queryFunctions.js similarity index 70% rename from apps/supernova/src/lib/queries/queryFunctions.js rename to apps/supernova/src/api/queryFunctions.js index 94b2f31c9..ef8d6c764 100644 --- a/apps/supernova/src/lib/queries/queryFunctions.js +++ b/apps/supernova/src/api/queryFunctions.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fetchAlerts } from "./alertsQueries" -import { fetchSilences } from "./silencesQueries" +import { fetchAlerts } from "./alerts" +import { fetchSilences } from "./silences" export const QUERY_FUNCTIONS = { alerts: fetchAlerts, diff --git a/apps/supernova/src/api/silences.js b/apps/supernova/src/api/silences.js new file mode 100644 index 000000000..87073664c --- /dev/null +++ b/apps/supernova/src/api/silences.js @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const fetchSilences = async (endpoint) => { + try { + const response = await fetch(`${endpoint}/silences`) + + if (!response.ok) { + // Parse the error object from the response body + const errorObject = await response.json().catch(() => { + throw new Error(`Unexpected error: Unable to parse error response.`) + }) + + // Throw the error object directly + throw errorObject + } + + const items = await response.json() // Parse JSON data + + // Return the structured result + return { + silences: items, + } + } catch (error) { + console.error(error) + throw error // Let React Query handle the error + } +} + +export const deleteSilences = async (variables) => { + try { + const response = await fetch(`${variables.endpoint}/silence/${variables.id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }) + + if (!response.ok) { + const errorDetails = await response.json().catch(() => null) + const errorMessage = errorDetails?.message || errorDetails || `Error ${response.status}: ${response.statusText}` + throw new Error(errorMessage) + } + return await response + } catch (error) { + console.error(error) + throw error // Let React Query handle the error + } +} + +export const createSilences = async (variables) => { + try { + const response = await fetch(`${variables.endpoint}/silences`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(variables.silence), + }) + + if (!response.ok) { + const errorDetails = await response.json().catch(() => null) + const errorMessage = errorDetails?.message || errorDetails || `Error ${response.status}: ${response.statusText}` + throw new Error(errorMessage) + } + return await response.json() + } catch (error) { + console.error(error) + throw error // Let React Query handle the error + } +} diff --git a/apps/supernova/src/components/StoreProvider.jsx b/apps/supernova/src/components/StoreProvider.jsx index 21f6b2a8b..d73083e38 100644 --- a/apps/supernova/src/components/StoreProvider.jsx +++ b/apps/supernova/src/components/StoreProvider.jsx @@ -78,11 +78,9 @@ export const useFilterActions = () => useAppStore((state) => state.filters.actio // Silences exports export const useSilencesItems = () => useAppStore((state) => state.silences.items) -export const useSilencesItemsHash = () => useAppStore((state) => state.silences.itemsHash) export const useSilencesExcludedLabels = () => useAppStore((state) => state.silences.excludedLabels) export const useSilencesIsUpdating = () => useAppStore((state) => state.silences.isUpdating) export const useSilencesUpdatedAt = () => useAppStore((state) => state.silences.updatedAt) -export const useSilencesLocalItems = () => useAppStore((state) => state.silences.localItems) export const useShowDetailsForSilence = () => useAppStore((state) => state.silences.showDetailsForSilence) export const useSilencesStatus = () => useAppStore((state) => state.silences.status) export const useSilencesRegEx = () => useAppStore((state) => state.silences.regEx) diff --git a/apps/supernova/src/components/alerts/Alert.jsx b/apps/supernova/src/components/alerts/Alert.jsx index 78e1fcfbd..dbe0d25fd 100644 --- a/apps/supernova/src/components/alerts/Alert.jsx +++ b/apps/supernova/src/components/alerts/Alert.jsx @@ -10,7 +10,7 @@ import { DataGridCell, DataGridRow } from "@cloudoperators/juno-ui-components" import { useGlobalsActions, useShowDetailsFor } from "../StoreProvider" import AlertLabels from "./shared/AlertLabels" import AlertLinks from "./shared/AlertLinks" -import SilenceNew from "../silences/SilenceNew" +import CreateSilence from "../silences/CreateSilence" import AlertIcon from "./shared/AlertIcon" import AlertDescription from "./shared/AlertDescription" import AlertTimestamp from "./shared/AlertTimestamp" @@ -93,7 +93,7 @@ const Alert = ({ alert }, ref) => { - + ) diff --git a/apps/supernova/src/components/alerts/AlertDetail.jsx b/apps/supernova/src/components/alerts/AlertDetail.jsx index 8c228442c..97eab62fd 100644 --- a/apps/supernova/src/components/alerts/AlertDetail.jsx +++ b/apps/supernova/src/components/alerts/AlertDetail.jsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from "react" +import React, { useEffect, useState } from "react" import { CodeBlock, Container, @@ -22,30 +22,37 @@ import { Tab, TabPanel, } from "@cloudoperators/juno-ui-components" -import { useShowDetailsFor, useGlobalsActions, useAlertsActions } from "../StoreProvider" +import { useShowDetailsFor, useGlobalsActions, useAlertsActions, useAlertsItems } from "../StoreProvider" import AlertIcon from "./shared/AlertIcon" import AlertTimestamp from "./shared/AlertTimestamp" import AlertDescription from "./shared/AlertDescription" import AlertLinks from "./shared/AlertLinks" import AlertLabels from "./shared/AlertLabels" -import SilenceNew from "../silences/SilenceNew" +import CreateSilence from "../silences/CreateSilence" import AlertStatus from "./AlertStatus" import AlertRegion from "./shared/AlertRegion" import AlertSilences from "./AlertSilences" -import { MessagesProvider, Messages } from "@cloudoperators/juno-messages-provider" +import { Messages } from "@cloudoperators/juno-messages-provider" import { useBoundQuery } from "../../hooks/useBoundQuery" const AlertDetail = () => { const alertID = useShowDetailsFor() const { setShowDetailsFor } = useGlobalsActions() const { getAlertByFingerprint } = useAlertsActions() - const alert = getAlertByFingerprint(alertID) + const [alert, setAlert] = useState(null) + const alerts = useAlertsItems() const onPanelClose = () => { setShowDetailsFor(null) } - const { isAlertsLoading } = useBoundQuery("alerts") + const { isLoading } = useBoundQuery("alerts") + useEffect(() => { + // wait for the alerts to be loaded + if (alerts?.length > 0) { + setAlert(getAlertByFingerprint(alertID)) + } + }, [alerts, alertID]) return ( { size="large" > - - - - - Details - Raw Data - - - - {!alert ? ( - isAlertsLoading ? ( - - Loading - - - ) : ( - "Not found - the alert is probably not firing at the moment" - ) + + + + Details + Raw Data + + + + {!alert ? ( + isLoading ? ( + + Loading + + ) : ( - <> - - - Status - - - - - - Firing Since - - - - - - Service - {alert?.labels?.service} - - - Region - - - - - - Description - - - - - - Links - - - - - - Labels - - - - - + "Not found - the alert is probably not firing at the moment" + ) + ) : ( + <> + + + Status + + + + + + Firing Since + + + + + + Service + {alert?.labels?.service} + + + Region + + + + + + Description + + + + + + Links + + + + + + Labels + + + + + - - - )} - - + + + )} + + - - - {alert && } - - - - + + + {alert && } + + + - {alert && } + {alert && } ) } diff --git a/apps/supernova/src/components/alerts/AlertSilences.jsx b/apps/supernova/src/components/alerts/AlertSilences.jsx index ffbef36a5..bdbc22af9 100644 --- a/apps/supernova/src/components/alerts/AlertSilences.jsx +++ b/apps/supernova/src/components/alerts/AlertSilences.jsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from "react" +import React, { useEffect } from "react" import { Button, @@ -12,19 +12,47 @@ import { DataGridCell, DataGridHeadCell, DataGridRow, + Stack, + Spinner, } from "@cloudoperators/juno-ui-components" - -import { useAlertsActions, useGlobalsActions } from "../StoreProvider" +import { useAlertsActions, useGlobalsActions, useSilencesActions } from "../StoreProvider" import AlertDescription from "./shared/AlertDescription" import AlertSilencesList from "./shared/AlertSilencesList" +import { useBoundQuery } from "../../hooks/useBoundQuery" const AlertSilences = ({ alert }) => { const { getAlertByFingerprint } = useAlertsActions() const { setShowDetailsFor } = useGlobalsActions() + const { setSilences } = useSilencesActions() + + // fetch silences + const { error, data, isLoading } = useBoundQuery("silences") + + useEffect(() => { + if (data) { + setSilences({ + items: data?.silences, + }) + } + }, [data]) + + if (error) { + addMessage({ + variant: "error", + text: parseError(error), + }) + } return ( - + {isLoading ? ( + + Loading + + + ) : ( + + )} {alert.status.inhibitedBy.length > 0 && ( <> diff --git a/apps/supernova/src/components/alerts/AlertStatus.jsx b/apps/supernova/src/components/alerts/AlertStatus.jsx index eace0733b..1fa2c1dfe 100644 --- a/apps/supernova/src/components/alerts/AlertStatus.jsx +++ b/apps/supernova/src/components/alerts/AlertStatus.jsx @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo } from "react" +import React from "react" import { Stack } from "@cloudoperators/juno-ui-components" -import { useSilencesItemsHash, useSilencesLocalItems, useSilencesActions, useAlertsItems } from "../StoreProvider" +import { useSilencesActions, useAlertsItems } from "../StoreProvider" // Gives inhibitor which will still last the longest export const getInhibitor = (alertsInhibitedBy, alerts) => { @@ -27,9 +27,7 @@ export const getInhibitor = (alertsInhibitedBy, alerts) => { const AlertStatus = ({ alert }) => { if (!alert) return null const alerts = useAlertsItems() - const allSilences = useSilencesItemsHash() - const localSilences = useSilencesLocalItems() - const { getMappingSilences, getMappedState } = useSilencesActions() + const { getMappingSilences } = useSilencesActions() // Gives silence which will still last the longest const silences = getMappingSilences(alert).sort((a, b) => new Date(b?.endsAt) - new Date(a?.endsAt)) @@ -37,24 +35,9 @@ const AlertStatus = ({ alert }) => { const inhibitor = getInhibitor(alert?.status?.inhibitedBy, alerts) - const state = useMemo(() => { - return getMappedState(alert) - }, [alert, allSilences, localSilences]) - return (
- {state && ( - <> - {state?.isProcessing ? ( - - {state.type} - processing - - ) : ( - {state.type} - )} - - )} + {alert && {alert?.status?.state}} {inhibitor && (
diff --git a/apps/supernova/src/components/alerts/AlertsList.jsx b/apps/supernova/src/components/alerts/AlertsList.jsx index 471a4121b..a0253bd63 100644 --- a/apps/supernova/src/components/alerts/AlertsList.jsx +++ b/apps/supernova/src/components/alerts/AlertsList.jsx @@ -3,103 +3,72 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo, useState, useRef, useCallback, useEffect } from "react" -import { DataGrid, DataGridHeadCell, DataGridRow, DataGridCell, Icon, Stack } from "@cloudoperators/juno-ui-components" +import React from "react" +import { + DataGrid, + DataGridHeadCell, + DataGridRow, + DataGridCell, + Icon, + Stack, + Spinner, + useEndlessScrollList, +} from "@cloudoperators/juno-ui-components" import Alert from "./Alert" -import { useAlertsItemsFiltered, useAlertsActions } from "../StoreProvider" -import { useBoundQuery } from "../../hooks/useBoundQuery" +import { useAlertsItemsFiltered } from "../StoreProvider" const AlertsList = () => { - const [visibleAmount, setVisibleAmount] = useState(20) - const [isAddingItems, setIsAddingItems] = useState(false) - const timeoutRef = React.useRef(null) - const itemsFiltered = useAlertsItemsFiltered() - const { setAlertsData } = useAlertsActions() - - const { data, isLoading } = useBoundQuery("alerts") - - useEffect(() => { - if (data) { - setAlertsData({ items: data.alerts, counts: data.counts }) - } - }, [data]) - - const alertsSorted = useMemo(() => { - if (itemsFiltered) { - return itemsFiltered.slice(0, visibleAmount) - } - }, [itemsFiltered, visibleAmount]) - React.useEffect(() => { - return () => clearTimeout(timeoutRef.current) // clear when component is unmounted - }, []) - - // endless scroll observer - const observer = useRef() - const lastListElementRef = useCallback( - (node) => { - // no fetch if loading original data - if (isLoading || isAddingItems) return - if (observer.current) observer.current.disconnect() - observer.current = new IntersectionObserver((entries) => { - console.debug("IntersectionObserver: callback") - if (entries[0].isIntersecting && visibleAmount <= alertsSorted.length) { - // setVisibleAmount((prev) => prev + 10) - clearTimeout(timeoutRef.current) - setIsAddingItems(true) - timeoutRef.current = setTimeout(() => { - setIsAddingItems(false) - setVisibleAmount((prev) => prev + 10) - }, 500) - } - }) - if (node) observer.current.observe(node) - }, - [isLoading, isAddingItems] - ) + const { scrollListItems, iterator } = useEndlessScrollList(itemsFiltered, { + loadingObject: ( + + + + Loading... + + + + + ), + refFunction: (ref) => ( + + + + + + ), + }) return ( - <> - - - - - Region - Service - Description - Firing Since - Status - + + + + + Region + Service + Description + Firing Since + Status + + + + {scrollListItems?.length > 0 ? ( + iterator.map((alert) => ) + ) : ( + + + + +
+ We couldn't find anything. It's possible that the matching alerts are not active at the + moment, or the chosen filters could be overly limiting. +
+
+
- {alertsSorted?.length > 0 ? ( - alertsSorted?.map((alert, index) => { - if (alertsSorted.length === index + 1) { - // DataRow in alerts muss implement forwardRef - return - } - return - }) - ) : ( - - - - -
- We couldn't find anything. It's possible that the matching alerts are not active at the - moment, or the chosen filters could be overly limiting. -
-
-
-
- )} - {isAddingItems && ( - - Loading ... - - )} - + )}
) } diff --git a/apps/supernova/src/components/alerts/AlertsTab.jsx b/apps/supernova/src/components/alerts/AlertsTab.jsx new file mode 100644 index 000000000..11012571c --- /dev/null +++ b/apps/supernova/src/components/alerts/AlertsTab.jsx @@ -0,0 +1,50 @@ +import React, { useEffect } from "react" +import { Stack, Spinner } from "@cloudoperators/juno-ui-components" +import { useBoundQuery } from "../../hooks/useBoundQuery" +import AlertsList from "./AlertsList" +import StatusBar from "../status/StatusBar" +import Filters from "../filters/Filters" +import { useActions } from "@cloudoperators/juno-messages-provider" +import PredefinedFilters from "../filters/PredefinedFilters" +import { useAlertsUpdatedAt, useAlertsTotalCounts, useAlertsActions } from "../StoreProvider" +import { parseError } from "../../helpers" +const AlertsTab = () => { + const totalCounts = useAlertsTotalCounts() + const updatedAt = useAlertsUpdatedAt() + const { setAlertsData } = useAlertsActions() + const { addMessage } = useActions() + + // Fetch alerts data + const { data, isLoading, error } = useBoundQuery("alerts") + if (error) { + addMessage({ + variant: "error", + text: parseError(error), + }) + } + useEffect(() => { + if (data) { + setAlertsData({ items: data.alerts, counts: data.counts }) + } + }, [data]) + + return ( + <> + {isLoading ? ( + + Loading + + + ) : ( + <> + + + + + + )} + + ) +} + +export default AlertsTab diff --git a/apps/supernova/src/components/alerts/shared/AlertSilencesList.jsx b/apps/supernova/src/components/alerts/shared/AlertSilencesList.jsx index f85ef1cf6..d8113e8fd 100644 --- a/apps/supernova/src/components/alerts/shared/AlertSilencesList.jsx +++ b/apps/supernova/src/components/alerts/shared/AlertSilencesList.jsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect } from "react" +import React from "react" import { DateTime } from "luxon" import constants from "../../../constants" import ExpireSilence from "../../silences/ExpireSilence" @@ -11,14 +11,12 @@ import RecreateSilence from "../../silences/RecreateSilence" import { Badge, DataGrid, DataGridCell, DataGridHeadCell, DataGridRow } from "@cloudoperators/juno-ui-components" -import { useSilencesActions, useSilencesLocalItems } from "../../StoreProvider" +import { useSilencesActions } from "../../StoreProvider" const badgeVariant = (state) => { switch (state) { case constants.SILENCE_STATE_ACTIVE: return "info" - case constants.SILENCE_CREATING: - return "warning" default: return "default" } @@ -26,7 +24,6 @@ const badgeVariant = (state) => { const AlertSilencesList = ({ alert }) => { const dateFormat = { ...DateTime.DATETIME_SHORT } - const localItems = useSilencesLocalItems() const { getSilencesForAlert } = useSilencesActions() let silenceList = getSilencesForAlert(alert) @@ -36,10 +33,6 @@ const AlertSilencesList = ({ alert }) => { return time.toLocaleString(dateFormat) } - useEffect(() => { - silenceList = getSilencesForAlert(alert) - }, [localItems]) - return ( <> {silenceList.length > 0 && ( @@ -69,9 +62,7 @@ const AlertSilencesList = ({ alert }) => { { /// show the expire button if the silence is active or pending // else show recreate button - silence?.status?.state === constants.SILENCE_ACTIVE || - silence?.status?.state === constants.SILENCE_PENDING || - silence?.status?.state === constants.SILENCE_CREATING ? ( + silence?.status?.state === constants.SILENCE_ACTIVE ? ( ) : ( diff --git a/apps/supernova/src/components/filters/PredefinedFilters.jsx b/apps/supernova/src/components/filters/PredefinedFilters.jsx index 9393db3d7..48f81a45c 100644 --- a/apps/supernova/src/components/filters/PredefinedFilters.jsx +++ b/apps/supernova/src/components/filters/PredefinedFilters.jsx @@ -7,7 +7,7 @@ import React, { useState } from "react" import { Stack, TabNavigation, TabNavigationItem } from "@cloudoperators/juno-ui-components" import { useActivePredefinedFilter, useFilterActions, usePredefinedFilters } from "../StoreProvider" -import SilenceScheduledWrapper from "../silences/SilenceScheduledWrapper" +import SilenceScheduled from "../silences/SilenceScheduled" const PredefinedFilters = () => { const { setActivePredefinedFilter } = useFilterActions() @@ -31,7 +31,7 @@ const PredefinedFilters = () => { )}
- +
) diff --git a/apps/supernova/src/components/silences/SilenceNew.jsx b/apps/supernova/src/components/silences/CreateSilence.jsx similarity index 82% rename from apps/supernova/src/components/silences/SilenceNew.jsx rename to apps/supernova/src/components/silences/CreateSilence.jsx index 673ad729a..9101a3c76 100644 --- a/apps/supernova/src/components/silences/SilenceNew.jsx +++ b/apps/supernova/src/components/silences/CreateSilence.jsx @@ -18,20 +18,24 @@ import { } from "@cloudoperators/juno-ui-components" import { useSilencesExcludedLabels, - useGlobalsApiEndpoint, useSilencesActions, useAlertEnrichedLabels, useGlobalsUsername, + useSilencesItems, } from "../StoreProvider" -import { post } from "../../api/client" import AlertDescription from "../alerts/shared/AlertDescription" -import SilenceNewAdvanced from "./SilenceNewAdvanced" -import { debounce } from "../../helpers" +import { useActions } from "@cloudoperators/juno-messages-provider" +import CreateSilenceAdvanced from "./CreateSilenceAdvanced" import { DateTime } from "luxon" import { latestExpirationDate, getSelectOptions, setupMatchers } from "./silenceHelpers" import { parseError } from "../../helpers" + +import { debounce } from "../../helpers" import constants from "../../constants" +import { useQueryClient } from "@tanstack/react-query" +import { useBoundMutation } from "../../hooks/useBoundMutation" + const validateForm = (values) => { const minCommentLength = 3 const minUserNameLength = 1 @@ -60,25 +64,27 @@ const errorHelpText = (messages) => { const DEFAULT_FORM_VALUES = { duration: "2", comment: "" } -const SilenceNew = ({ alert, size, variant }) => { - const apiEndpoint = useGlobalsApiEndpoint() +const CreateSilence = ({ alert, size, variant }) => { + const queryClient = useQueryClient() const excludedLabels = useSilencesExcludedLabels() - const { addLocalItem, getMappingSilences } = useSilencesActions() + const { getMappingSilences, setSilences } = useSilencesActions() const enrichedLabels = useAlertEnrichedLabels() const user = useGlobalsUsername() - const [displayNewSilence, setDisplayNewSilence] = useState(false) const [formState, setFormState] = useState(DEFAULT_FORM_VALUES) const [expirationDate, setExpirationDate] = useState(null) const [showValidation, setShowValidation] = useState({}) const [error, setError] = useState(null) + const { addMessage } = useActions() + + const silences = useSilencesItems() + const [success, setSuccess] = useState(null) // Initialize form state on modal open // Removed alert from dependencies since we take an screenshot of the global state on opening the modal // This is due to if the alert changes (e.g. the alert receives a new silenceBy) while the modal is open, the form state will be reset useLayoutEffect(() => { - if (!displayNewSilence) return // reset form state with default values setFormState({ ...formState, @@ -108,6 +114,41 @@ const SilenceNew = ({ alert, size, variant }) => { return options.items }, [expirationDate]) + const { mutate: createSilence } = useBoundMutation("createSilences", { + onMutate: (data) => { + queryClient.cancelQueries("silences") + + const newSilence = { ...data.silence, status: { state: constants.SILENCE_ACTIVE } } + + const newSilences = [...silences, newSilence] + + setSilences({ + items: newSilences, + }) + + setDisplayNewSilence(false) + }, + + onSuccess: (data) => { + addMessage({ + variant: "success", + text: `A silence object with id ${data?.silenceID} was created successfully. Please note that it may + take up to 5 minutes for the alert to show up as silenced.`, + }) + }, + onError: (error) => { + // add a error message in UI + addMessage({ + variant: "error", + text: parseError(error), + }) + }, + onSettled: () => { + // Optionale zusätzliche Aktionen, wie das erneute Abrufen von Daten + queryClient.invalidateQueries(["silences"]) + }, + }) + // debounce to prevent accidental double clicks from creating multiple silences const onSubmitForm = debounce(() => { setError(null) @@ -134,24 +175,8 @@ const SilenceNew = ({ alert, size, variant }) => { alertFingerprint: alert.fingerprint, } - // submit silence - post(`${apiEndpoint}/silences`, { - body: JSON.stringify(newSilence), - }) - .then((data) => { - setSuccess(data) - if (data?.silenceID) { - // add silence to local store - addLocalItem({ - silence: newSilence, - id: data.silenceID, - type: "local", - }) - } - }) - .catch((error) => { - setError(parseError(error)) - }) + // calling createSilence with variable silence: newSilence + createSilence({ silence: newSilence }) }, 200) const onInputChanged = ({ key, value }) => { @@ -214,7 +239,7 @@ const SilenceNew = ({ alert, size, variant }) => { {!success && ( <> - +
@@ -263,4 +288,4 @@ const SilenceNew = ({ alert, size, variant }) => { ) } -export default SilenceNew +export default CreateSilence diff --git a/apps/supernova/src/components/silences/SilenceNewAdvanced.jsx b/apps/supernova/src/components/silences/CreateSilenceAdvanced.jsx similarity index 100% rename from apps/supernova/src/components/silences/SilenceNewAdvanced.jsx rename to apps/supernova/src/components/silences/CreateSilenceAdvanced.jsx diff --git a/apps/supernova/src/components/silences/ExpireSilence.jsx b/apps/supernova/src/components/silences/ExpireSilence.jsx index 78bc622fd..3ee4dc178 100644 --- a/apps/supernova/src/components/silences/ExpireSilence.jsx +++ b/apps/supernova/src/components/silences/ExpireSilence.jsx @@ -5,53 +5,58 @@ import React, { useState } from "react" import { Button, Modal } from "@cloudoperators/juno-ui-components" -import { useGlobalsApiEndpoint, useSilencesActions } from "../StoreProvider" import { useActions } from "@cloudoperators/juno-messages-provider" import { parseError } from "../../helpers" -import constants from "../../constants" +import { useBoundMutation } from "../../hooks/useBoundMutation" +import { useQueryClient } from "@tanstack/react-query" import { debounce } from "../../helpers" -import { del } from "../../api/client" +import { useSilencesItems, useSilencesActions } from "../StoreProvider" +import constants from "../../constants" const ExpireSilence = (props) => { const { addMessage } = useActions() const silence = props.silence - const fingerprint = props.fingerprint ? props.fingerprint : null const [confirmationDialog, setConfirmationDialog] = useState(false) - const apiEndpoint = useGlobalsApiEndpoint() - const { addLocalItem } = useSilencesActions() + const queryClient = useQueryClient() + const silences = useSilencesItems() + const { setSilences } = useSilencesActions() - // debounce to prevent accidental double clicks from firing multiple api calls - const onExpire = debounce(() => { - // submit silence - del(`${apiEndpoint}/silence/${silence.id}`) - .then(() => { - addMessage({ - variant: "success", - text: `Silence ${silence.id} expired successfully. Please note that it may take up to 5 minutes for the silence to show up as expired.`, - }) + const { mutate: deleteSilences } = useBoundMutation("deleteSilences", { + onSuccess: (data) => { + queryClient.cancelQueries("silences") + + const updatedSilences = silences.filter((item) => item.id === data.id) + let updatedSilence = updatedSilences.length > 0 ? updatedSilences[0] : null + updatedSilence = { ...updatedSilence, status: { state: constants.SILENCE_EXPIRED } } + + const newSilences = [...silences.filter((item) => item?.id !== data?.id), updatedSilence] + + setSilences({ + items: newSilences, }) - .catch((error) => { - addMessage({ - variant: "error", - text: parseError(error), - }) + addMessage({ + variant: "success", + text: `Silence expired successfully. Please note that it may take up to 5 minutes for the silence to show up as expired.`, }) + }, - setConfirmationDialog(false) - // set local silence to override old with expiring and refetch silences + onError: (error) => { + // add a error message in UI + addMessage({ + variant: "error", + text: parseError(error), + }) + }, - let newSilence = { - ...silence, - status: { ...silence.status, state: constants.SILENCE_EXPIRING }, - } - addLocalItem({ - silence: newSilence, - id: newSilence.id, - type: constants.SILENCE_EXPIRING, - alertFingerprint: fingerprint, - }) + onSettled: () => { + // Optionale zusätzliche Aktionen, wie das erneute Abrufen von Daten + queryClient.invalidateQueries(["silences"]) + }, + }) - return + const onExpire = debounce(() => { + deleteSilences({ id: silence.id }) + setConfirmationDialog(false) }, 200) return ( diff --git a/apps/supernova/src/components/silences/RecreateSilence.jsx b/apps/supernova/src/components/silences/RecreateSilence.jsx index 0574199ad..c8e874f67 100644 --- a/apps/supernova/src/components/silences/RecreateSilence.jsx +++ b/apps/supernova/src/components/silences/RecreateSilence.jsx @@ -17,12 +17,14 @@ import { Pill, Stack, } from "@cloudoperators/juno-ui-components" -import { useGlobalsApiEndpoint, useSilencesActions, useGlobalsUsername } from "../StoreProvider" -import { debounce } from "../../helpers" -import { post } from "../../api/client" +import { useBoundMutation } from "../../hooks/useBoundMutation" +import { useGlobalsUsername, useSilencesItems, useSilencesActions } from "../StoreProvider" + +import { useActions } from "@cloudoperators/juno-messages-provider" import { DateTime } from "luxon" import { latestExpirationDate, getSelectOptions } from "./silenceHelpers" -import { parseError } from "../../helpers" +import { debounce, parseError } from "../../helpers" +import { useQueryClient } from "@tanstack/react-query" import constants from "../../constants" const validateForm = (values) => { @@ -55,18 +57,20 @@ const DEFAULT_FORM_VALUES = { duration: "2", comment: "" } const RecreateSilence = (props) => { const silence = props.silence - const fingerprint = props.fingerprint ? props.fingerprint : null - const apiEndpoint = useGlobalsApiEndpoint() + + const { addMessage } = useActions() const user = useGlobalsUsername() - const { addLocalItem } = useSilencesActions() + const silences = useSilencesItems() + const { setSilences } = useSilencesActions() + + const queryClient = useQueryClient() const [displayNewSilence, setDisplayNewSilence] = useState(false) const [formState, setFormState] = useState(DEFAULT_FORM_VALUES) const [expirationDate, setExpirationDate] = useState(null) const [showValidation, setShowValidation] = useState({}) const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) // Initialize form state on modal open // Removed alert from dependencies since we take an screenshot of the global state on opening the modal @@ -88,7 +92,6 @@ const RecreateSilence = (props) => { setExpirationDate(latestExpirationDate()) // reset other states setError(null) - setSuccess(null) setShowValidation({}) }, [displayNewSilence]) @@ -103,10 +106,43 @@ const RecreateSilence = (props) => { return options.items }, [expirationDate]) + const { mutate: createSilence } = useBoundMutation("createSilences", { + onMutate: (data) => { + queryClient.cancelQueries("silences") + + const newSilence = { ...data.silence, status: { state: constants.SILENCE_ACTIVE } } + const newSilences = [...silences, newSilence] + + setSilences({ + items: newSilences, + }) + + setDisplayNewSilence(false) + }, + onSuccess: (data) => { + addMessage({ + variant: "success", + text: `A silence object with id ${data?.silenceID} was created successfully. Please note that it may + take up to 5 minutes for the alert to show up as silenced.`, + }) + }, + onError: (error) => { + // add a error message in UI + addMessage({ + variant: "error", + text: parseError(error), + }) + }, + + onSettled: () => { + // Optionale zusätzliche Aktionen, wie das erneute Abrufen von Daten + queryClient.invalidateQueries(["silences"]) + }, + }) + // debounce to prevent accidental double clicks from creating multiple silences const onSubmitForm = debounce(() => { setError(null) - setSuccess(null) const formValidation = validateForm(formState) setShowValidation(formValidation) if (Object.keys(formValidation).length > 0) return @@ -119,30 +155,12 @@ const RecreateSilence = (props) => { const newSilence = { ...newFormState, - status: { state: constants.SILENCE_CREATING }, startsAt: startsAt.toISOString(), endsAt: endsAt.toISOString(), } - // submit silence - post(`${apiEndpoint}/silences`, { - body: JSON.stringify(newSilence), - }) - .then((data) => { - setSuccess(data) - if (data?.silenceID) { - // add silence to local store - addLocalItem({ - silence: newSilence, - id: data.silenceID, - type: constants.SILENCE_CREATING, - alertFingerprint: fingerprint, - }) - } - }) - .catch((error) => { - setError(parseError(error)) - }) + // calling createSilence with variable silence: newSilence + createSilence({ silence: newSilence }) }, 200) const onInputChanged = ({ key, value }) => { @@ -165,86 +183,77 @@ const RecreateSilence = (props) => { title="New Silence for" size="large" open={true} - confirmButtonLabel={success ? null : "Save"} + confirmButtonLabel={"Save"} onCancel={() => setDisplayNewSilence(false)} - onConfirm={success ? null : onSubmitForm} + onConfirm={onSubmitForm} > {error && } - {success && ( - - A silence object with id {success?.silenceID} was created successfully. Please note that it may - take up to 5 minutes for the alert to show up as silenced. - - )} - - {expirationDate && !success && ( + {expirationDate && ( There is already a silence for this alert that expires at {DateTime.fromISO(expirationDate).toLocaleString(DateTime.DATETIME_SHORT)} )} - {!success && ( - <> -
-

Matchers attached to this silence

- - - {formState?.matchers && - Object.keys(formState?.matchers).map((label, index) => ( - - ))} - -
- - - - onInputChanged({ key: "createdBy", value: e.target.value })} - errortext={showValidation["createdBy"] && errorHelpText(showValidation["createdBy"])} - /> - - - + label="Silence Template" + value={selected?.id || "Select"} + onValueChange={(value) => { + onChangeTemplate(value) + }} + > + {silenceTemplates?.map((option) => ( + + ))} + - - {formState?.editable_labels && Object.keys(formState?.editable_labels).length > 0 && ( - + {selected && !selected?.invalid && ( -

Editable Labels are labels that are editable. You can use regular expressions.

+ {selected?.description}
- - -
- {Object.keys(formState.editable_labels).map((editable_label, index) => ( - + + {selected && !selected?.invalid && ( + + + + onInputChanged({ key: "createdBy", value: e.target.value })} + disabled={!!user} + /> + + +
+ - ))} -
-
-
- )} - - {Object.keys(formState?.fixed_labels).length > 0 && ( - - -

Fixed Labels are labels that are not editable.

-
- - - - {Object.keys(formState.fixed_labels).map((label, index) => ( - - ))} - - -
+
+
+ + + + +
+ + {formState?.editable_labels && Object.keys(formState?.editable_labels).length > 0 && ( + + +

Editable Labels are labels that are editable. You can use regular expressions.

+
+ + +
+ {Object.keys(formState.editable_labels).map((editable_label, index) => ( + + ))} +
+
+
+ )} + + {Object.keys(formState?.fixed_labels).length > 0 && ( + + +

Fixed Labels are labels that are not editable.

+
+ + + + {Object.keys(formState.fixed_labels).map((label, index) => ( + + ))} + + +
+ )} + )} - - )} + + )}{" "} )} - + ) } diff --git a/apps/supernova/src/components/silences/SilenceScheduledWrapper.jsx b/apps/supernova/src/components/silences/SilenceScheduledWrapper.jsx deleted file mode 100644 index 6247f61b0..000000000 --- a/apps/supernova/src/components/silences/SilenceScheduledWrapper.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState } from "react" -import SilenceScheduled from "./SilenceScheduled" - -import { MessagesProvider } from "@cloudoperators/juno-messages-provider" -import { Button } from "@cloudoperators/juno-ui-components" -import { useSilenceTemplates } from "../StoreProvider" - -const SilenceScheduledWrapper = () => { - const templates = useSilenceTemplates() - const [displayNewScheduledSilence, setDisplayNewScheduledSilence] = useState(false) - - // function which sets displayNewScheduledSilence to false - const callbackOnClose = () => { - setDisplayNewScheduledSilence(false) - } - - return ( - <> - {templates && templates?.length > 0 && ( - - - {displayNewScheduledSilence && } - - )} - - ) -} - -export default SilenceScheduledWrapper diff --git a/apps/supernova/src/components/silences/SilencesItem.jsx b/apps/supernova/src/components/silences/SilencesItem.jsx index c426b7519..643c30664 100644 --- a/apps/supernova/src/components/silences/SilencesItem.jsx +++ b/apps/supernova/src/components/silences/SilencesItem.jsx @@ -43,8 +43,7 @@ const SilencesItem = ({ silence }, ref) => { { /// show the expire button only if the silence is active or pending silence?.status?.state === constants.SILENCE_ACTIVE || - silence?.status?.state === constants.SILENCE_PENDING || - silence?.status?.state === constants.SILENCE_CREATING ? ( + silence?.status?.state === constants.SILENCE_PENDING ? ( ) : ( diff --git a/apps/supernova/src/components/silences/SilencesList.jsx b/apps/supernova/src/components/silences/SilencesList.jsx index 68801a0f5..bd965868b 100644 --- a/apps/supernova/src/components/silences/SilencesList.jsx +++ b/apps/supernova/src/components/silences/SilencesList.jsx @@ -18,15 +18,11 @@ import { useEndlessScrollList, } from "@cloudoperators/juno-ui-components" import constants from "../../constants" -import { - useSilencesItems, - useSilencesActions, - useSilencesRegEx, - useSilencesStatus, - useSilencesLocalItems, -} from "../StoreProvider" +import { useSilencesItems, useSilencesActions, useSilencesRegEx, useSilencesStatus } from "../StoreProvider" import SilencesItem from "./SilencesItem" import { useBoundQuery } from "../../hooks/useBoundQuery" +import { parseError } from "../../helpers" +import { useActions } from "@cloudoperators/juno-messages-provider" const filtersStyles = ` bg-theme-background-lvl-1 @@ -40,17 +36,22 @@ const SilencesList = () => { const [visibleSilences, setVisibleSilences] = useState(silences) const status = useSilencesStatus() const regEx = useSilencesRegEx() - const localSilences = useSilencesLocalItems() const { setSilences, setSilencesStatus, setSilencesRegEx } = useSilencesActions() + const { addMessage } = useActions() + + const { data, isLoading, error } = useBoundQuery("silences") - const { data } = useBoundQuery("silences") + if (error) { + addMessage({ + variant: "error", + text: parseError(error), + }) + } useEffect(() => { if (data) { setSilences({ items: data?.silences, - itemsHash: data?.silencesHash, - itemsByState: data?.silencesBySate, }) } }, [data]) @@ -68,31 +69,8 @@ const SilencesList = () => { filtered = filtered.filter((silence) => JSON.stringify(silence).toLowerCase().includes(regEx.toLowerCase)) } - if (localSilences) { - // when selected silences status is pending: if localSilence.status.state is creating add them to filtered - if (status === constants.SILENCE_PENDING) { - for (const [, localSilence] of Object.entries(localSilences)) { - if (localSilence.status.state === constants.SILENCE_CREATING) { - filtered.push(localSilence) - } - } - } - - // when selected silences status is active: if silence.id is in localSilences add the localSilence to the shownSilences else the filtered silence - if (status === constants.SILENCE_ACTIVE) { - filtered = filtered.map((silence) => { - for (const [, localSilence] of Object.entries(localSilences)) { - if (silence.id === localSilence.id) { - return localSilence - } - } - return silence - }) - } - } - setVisibleSilences(filtered) - }, [status, regEx, silences, localSilences]) + }, [status, regEx, silences]) const handleSearchChange = (value) => { // debounce setSearchTerm to avoid unnecessary re-renders @@ -125,58 +103,67 @@ const SilencesList = () => { return ( <> - - - - { - handleSearchChange(text) - }} - onSearch={(text) => { - setSilencesRegEx(text) - }} - onClear={() => { - setSilencesRegEx(null) - }} - /> - - - + {isLoading ? ( + + Loading + + + ) : ( <> - - Time intervall - Details - State - Action - - {scrollListItems?.length > 0 ? ( - iterator.map((silence) => ) - ) : ( - - - - -
We couldn't find any matching silences.
-
-
-
- )} + + + + { + handleSearchChange(text) + }} + onSearch={(text) => { + setSilencesRegEx(text) + }} + onClear={() => { + setSilencesRegEx(null) + }} + /> + + + + <> + + Time intervall + Details + State + Action + + {scrollListItems?.length > 0 ? ( + iterator.map((silence) => ) + ) : ( + + + + +
We couldn't find any matching silences.
+
+
+
+ )} + +
-
+ )} ) } diff --git a/apps/supernova/src/constants.js b/apps/supernova/src/constants.js index 77fc79cb3..3214af257 100644 --- a/apps/supernova/src/constants.js +++ b/apps/supernova/src/constants.js @@ -7,8 +7,6 @@ const constants = { SILENCE_ACTIVE: "active", SILENCE_PENDING: "pending", SILENCE_EXPIRED: "expired", - SILENCE_CREATING: "creating", - SILENCE_EXPIRING: "expiring", } export default constants diff --git a/apps/supernova/src/hooks/useAlertmanagerAPI.js b/apps/supernova/src/hooks/useAlertmanagerAPI.js deleted file mode 100644 index 635b74f1a..000000000 --- a/apps/supernova/src/hooks/useAlertmanagerAPI.js +++ /dev/null @@ -1,148 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useEffect } from "react" -import { - useAlertsActions, - useUserIsActive, - useSilencesActions, - useSilencesLocalItems, -} from "../components/StoreProvider" -import AlertsWorker from "../workers/alerts.js?worker&inline" -import SilencesWorker from "../workers/silences.js?worker&inline" - -const alertsWorker = new AlertsWorker() -const silencesWorker = new SilencesWorker() - -const useAlertmanagerAPI = (apiEndpoint) => { - const { - setAlertsData, - setIsLoading: setAlertsIsLoading, - setIsUpdating: setAlertsIsUpdating, - setError: setAlertsError, - } = useAlertsActions() - - const isUserActive = useUserIsActive() - - const { setSilences, setIsUpdating: setSilencesIsUpdating, setError: setSilencesError } = useSilencesActions() - - // Setup web workers - useEffect(() => { - let cleanupAlertsWorker = () => alertsWorker.terminate() - let cleanupSilencesWorker = () => silencesWorker.terminate() - - // receive messages from worker - alertsWorker.onmessage = (e) => { - const action = e.data.action - switch (action) { - case "ALERTS_UPDATE": - console.debug("Worker::ALERT_UPDATE::", e.data) - setAlertsData({ items: e.data.alerts, counts: e.data.counts }) - break - case "ALERTS_FETCH_START": - console.debug("Worker::ALERTS_FETCH_START::") - setAlertsIsUpdating(true) - break - case "ALERTS_FETCH_END": - console.debug("Worker::ALERTS_FETCH_END::") - setAlertsIsUpdating(false) - break - case "ALERTS_FETCH_ERROR": - console.debug("Worker::ALERTS_FETCH_ERROR::", e.data.error) - setAlertsIsUpdating(false) - // error comes as object string and have to be parsed - setAlertsError(e.data.error) - break - } - } - - // receive messages from worker - silencesWorker.onmessage = (e) => { - const action = e.data.action - switch (action) { - case "SILENCES_UPDATE": - console.debug("Worker::SILENCES_UPDATE::", e.data) - setSilences({ - items: e.data?.silences, - itemsHash: e.data?.silencesHash, - itemsByState: e.data?.silencesBySate, - }) - break - case "SILENCES_FETCH_START": - console.debug("Worker::SILENCES_FETCH_START::") - setSilencesIsUpdating(true) - break - case "SILENCES_FETCH_END": - console.debug("Worker::SILENCES_FETCH_END::") - setSilencesIsUpdating(false) - break - case "SILENCES_FETCH_ERROR": - console.debug("Worker::SILENCES_FETCH_ERROR::", e.data.error) - setSilencesIsUpdating(false) - // error comes as object string and have to be parsed - setSilencesError(e.data.error) - break - } - } - - return () => { - cleanupAlertsWorker() - cleanupSilencesWorker() - } - }, []) - - // Reconfigure workers each time we get a new API endpoint - useEffect(() => { - if (!apiEndpoint) return - - setAlertsIsLoading(true) - alertsWorker.postMessage({ - action: "ALERTS_CONFIGURE", - fetchVars: { apiEndpoint, options: {} }, - debug: true, - }) - - silencesWorker.postMessage({ - action: "SILENCES_CONFIGURE", - apiEndpoint, - }) - }, [apiEndpoint]) - - // Enable or disable watching in the workers based on user activity - useEffect(() => { - if (isUserActive === undefined) return - - alertsWorker.postMessage({ - action: "ALERTS_CONFIGURE", - watch: isUserActive, - }) - - silencesWorker.postMessage({ - action: "SILENCES_CONFIGURE", - watch: isUserActive, - }) - }, [isUserActive]) - - // Handle re-fetching silences when local items change - const localItems = useSilencesLocalItems() - useEffect(() => { - // if we have no silences locally we don't need to refetch them otherwise - // we will end up in an infinite loop - if (!localItems || Object.keys(localItems).length <= 0) return - - // Use setTimeout to delay the worker call delayed by 10s - setTimeout(() => { - silencesWorker.postMessage({ action: "SILENCES_FETCH" }) - }, 10000) - - return () => { - if (silencesWorker) { - silencesWorker.terminate() - } - } - }, [localItems]) -} - -export default useAlertmanagerAPI diff --git a/apps/supernova/src/hooks/useBoundMutation.js b/apps/supernova/src/hooks/useBoundMutation.js new file mode 100644 index 000000000..84c252690 --- /dev/null +++ b/apps/supernova/src/hooks/useBoundMutation.js @@ -0,0 +1,19 @@ +// useBoundMutation.js +import { useMutation } from "@tanstack/react-query" +import { MUTATION_FUNCTIONS } from "../api/mutationFunctions" +import { useGlobalsApiEndpoint } from "../components/StoreProvider" + +export const useBoundMutation = (key, options = {}) => { + const endpoint = useGlobalsApiEndpoint() + + const mutationFn = MUTATION_FUNCTIONS[key] + if (!mutationFn) { + throw new Error(`No mutation function mapped for key: ${key}`) + } + + return useMutation({ + mutationFn: (variables) => mutationFn({ ...variables, endpoint }), + onError: options?.onError, + ...options, + }) +} diff --git a/apps/supernova/src/hooks/useBoundQuery.js b/apps/supernova/src/hooks/useBoundQuery.js index d2db13b68..ed55666ec 100644 --- a/apps/supernova/src/hooks/useBoundQuery.js +++ b/apps/supernova/src/hooks/useBoundQuery.js @@ -4,7 +4,7 @@ */ import { useQuery } from "@tanstack/react-query" -import { QUERY_FUNCTIONS } from "../lib/queries/queryFunctions" +import { QUERY_FUNCTIONS } from "../api/queryFunctions" import { useGlobalsApiEndpoint } from "../components/StoreProvider" export const useBoundQuery = (key, { options } = {}) => { diff --git a/apps/supernova/src/lib/createSilencesSlice.jsx b/apps/supernova/src/lib/createSilencesSlice.jsx index 0cb3fc00b..552f8dffe 100644 --- a/apps/supernova/src/lib/createSilencesSlice.jsx +++ b/apps/supernova/src/lib/createSilencesSlice.jsx @@ -3,16 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { produce } from "immer" -import constants from "../constants" - const initialSilencesState = { items: [], - itemsHash: {}, - itemsByState: {}, excludedLabels: [], updatedAt: null, - localItems: {}, status: "active", regEx: "", @@ -124,153 +118,43 @@ const createSilencesSlice = (set, get, options) => ({ return get().silences.items.find((silence) => silence.id === id) }, - setSilences: ({ items, itemsHash, itemsByState }) => { + setSilences: ({ items }) => { if (!items) return set((state) => ({ silences: { ...state.silences, items: items, - itemsHash: itemsHash, - itemsByState: itemsByState, updatedAt: Date.now(), }, })), false, "silences.setSilencesData" - - // check if any local item can be removed - get().silences.actions.updateLocalItems() - }, - /* - Save temporary created silences to be able to display which alert is silenced - and who silenced it until the next alert fetch contains the silencedBy reference - */ - addLocalItem: ({ silence, id, type }) => { - // enforce silences with id and alertFingerprint - if (!silence || !id || !type) return - return set( - produce((state) => { - state.silences.localItems = { - ...get().silences.localItems, - [id]: { - ...silence, - id, - type: type, - }, - } - }), - false, - "silences.addLocalItem" - ) }, - /* - Remove local silences which are already referenced by an alert - */ - updateLocalItems: () => { - const allSilences = get().silences.itemsHash - - const SilencesByState = get().silences.itemsByState - let newLocalSilences = { ...get().silences.localItems } - Object.keys(newLocalSilences).forEach((key) => { - // if mapped to alert second logic - if (!newLocalSilences[key]?.alertFingerprint) { - // when newLocalSilences[key].silenceId with a creating state is in aktive SilencesByState, then remove it - if ( - newLocalSilences[key]?.status?.state === constants.SILENCE_CREATING && - (SilencesByState?.active?.find((silence) => silence?.id === newLocalSilences[key]?.id) || - SilencesByState?.pending?.find((silence) => silence?.id === newLocalSilences[key]?.id)) - ) { - newLocalSilences[key] = { ...newLocalSilences[key], remove: true } - } - // when newLocalSilences[key].silenceId with a expiring state is in expired SilencesByState, then remove it - if ( - newLocalSilences[key]?.status?.state === constants.SILENCE_EXPIRING && - SilencesByState?.expired?.find((silence) => silence?.id === newLocalSilences[key]?.id) - ) { - newLocalSilences[key] = { ...newLocalSilences[key], remove: true } - } - - // continue to next iteration - return - } - - const alert = get().alerts.actions.getAlertByFingerprint(newLocalSilences[key]?.alertFingerprint) - // check if the alert has already the silence reference and if the extern silence already exists - const silencedBy = alert?.status?.silencedBy - if (silencedBy?.length > 0 && silencedBy?.includes(newLocalSilences[key]?.id) && allSilences[key]) { - // mark to remove silence - newLocalSilences[key] = { ...newLocalSilences[key], remove: true } - } - }) - - // remove silences marked to remove - const reducedLocalSilences = Object.keys(newLocalSilences) - .filter((key) => !newLocalSilences[key]?.remove) - .reduce((obj, key) => { - obj[key] = newLocalSilences[key] - return obj - }, {}) - - return set( - produce((state) => { - state.silences.localItems = reducedLocalSilences - }), - false, - "silences.updateLocalItems" - ) - }, /* - Given an alert fingerprint, this function returns all silences referenced by silencingBy. It also - check if there are local silences with the same alert fingerprint and return them as well. + Given an alert fingerprint, this function returns all silences referenced by silencingBy. */ getMappingSilences: (alert) => { if (!alert) return - const externalSilences = get().silences.itemsHash + const externalSilences = get().silences.items let silencedBy = alert?.status?.silencedBy || [] // ensure silencedBy is an array if (!Array.isArray(silencedBy)) silencedBy = [silencedBy] - let mappingSilences = [] - silencedBy.forEach((id) => { - if (externalSilences[id]) { - mappingSilences.push(externalSilences[id]) - } - }) - // add local silences - let localSilences = get().silences.localItems - Object.keys(localSilences).forEach((silenceID) => { - // if there is already a silence with the same id, skip it and exists as external silence - if (silencedBy.includes(silenceID) && externalSilences[silenceID]) return - // if the local silence has the same alert fingerprint, add it to the mapping silences - if (localSilences[silenceID]?.alertFingerprint === alert?.fingerprint) { - mappingSilences.push(localSilences[silenceID]) - } - }) + const silencedBySet = new Set(silencedBy) + const mappingSilences = externalSilences.filter((silence) => silencedBySet.has(silence.id)) + return mappingSilences }, /* - Return the state of an alert. If the alert is silenced by a local silence, the state is suppressed (processing) - */ - getMappedState: (alert) => { - if (!alert) return - // get all silences (local and external) - const silences = get().silences.actions.getMappingSilences(alert) - // if there is a silence with type local, return suppressed (processing) - if (silences?.find((silence) => silence?.type === "local")) { - return { type: "suppressed", isProcessing: true } - } - return { type: alert?.status?.state, isProcessing: false } - }, - /* - Find all silences in itemsByState with key expired that matches all labels (key&value) from the alert but omit the labels that are excluded (excludedLabels) + Find all silences with key expired that matches all labels (key&value) from the alert but omit the labels that are excluded (excludedLabels) */ getExpiredSilences: (alert) => { if (!alert) return const alertLabels = alert?.labels || {} - const silences = get().silences.itemsByState?.expired || [] + const silences = get().silences.items.filter((silence) => silence?.status?.state === "expired") || [] const excludedLabels = get().silences.excludedLabels || [] const enrichedLabels = get().alerts.enrichedLabels || [] // combine the arrays containing the labels that shouldn't be used for matching into one for easier checking @@ -301,22 +185,6 @@ const createSilencesSlice = (set, get, options) => ({ // collect all silences let silences = [...get().silences.items] - const localItems = get().silences.localItems || {} - - // checking if localItem need to overwrite a item or if its appended to silences - for (const key in localItems) { - const localSilence = localItems[key] - - const index = silences.findIndex((silence) => silence.id === localSilence.id) - - if (index !== -1) { - // Update the existing element - silences[index] = localSilence - } else { - // Add the new element - silences.unshift(localSilence) - } - } // collect all excluded Labels const excludedLabels = get().silences.excludedLabels || [] diff --git a/apps/supernova/src/lib/createSilencesSlice.test.jsx b/apps/supernova/src/lib/createSilencesSlice.test.jsx index 0952ba361..cfdced815 100644 --- a/apps/supernova/src/lib/createSilencesSlice.test.jsx +++ b/apps/supernova/src/lib/createSilencesSlice.test.jsx @@ -7,256 +7,20 @@ import * as React from "react" import { renderHook, act } from "@testing-library/react" import { useSilencesActions, - useSilencesLocalItems, useAlertsActions, - useAlertsItems, useSilencesExcludedLabels, StoreProvider, } from "../components/StoreProvider" -import { - createFakeAlertStatustWith, - createFakeAlertWith, - createFakeSilenceWith, - createFakeSilenceWithoutAlertFingerprint, -} from "./fakeObjects" +import { createFakeAlertStatustWith, createFakeAlertWith, createFakeSilenceWith } from "./fakeObjects" import { countAlerts } from "./utils" -describe("addLocalItem", () => { - it("should append the object with key silence id and value the silence itself", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWith() - - act(() => { - store.result.current.actions.addLocalItem({ - silence: silence, - id: "test", - type: "local", - }) - }) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - expect(store.result.current.localSilences["test"]["id"]).toEqual("test") - expect(store.result.current.localSilences["test"].alertFingerprint).toEqual("123") - }) - it("should avoid to add any silences without id", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWith() - act(() => - store.result.current.actions.addLocalItem({ - silence, - id: "", - type: "local", - }) - ) - act(() => - store.result.current.actions.addLocalItem({ - silence, - id: null, - type: "local", - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(0) - }) - it("should add silences with expiring-type and without alert fingerprint. it should delete the silence if a silence with the same id is set in expired state", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWithoutAlertFingerprint({ - status: { state: "expiring" }, - }) - // add a local silence with type expiring - act(() => { - store.result.current.actions.addLocalItem({ - silence: silence, - id: "test", - type: "expiring", - }) - }) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - expect(store.result.current.localSilences["test"]["id"]).toEqual("test") - - // set a silence with the same id in expired so it should be deleted (triggers updateLocalItems()) - act(() => - store.result.current.actions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { expired: [silence] }, - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(0) - }) - - it("should add items with and expiring type and the local silence should stay if a active silence is set", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWithoutAlertFingerprint({ - status: { state: "expiring" }, - }) - - act(() => { - store.result.current.actions.addLocalItem({ - silence: silence, - id: "test", - type: "expiring", - }) - }) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - expect(store.result.current.localSilences["test"]["id"]).toEqual("test") - // set a silence with the same id in active so it should not be deleted because - // local silence is expiring (triggers updateLocalItems()) - - act(() => - store.result.current.actions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - }) - - it("should add silences with creating type and delete them if they are set in active silences if they dont have a alertfingerprint", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWithoutAlertFingerprint({ - status: { state: "creating" }, - }) - - const silence2 = createFakeSilenceWith({ - id: "test2", - status: { state: "creating" }, - }) - // add a local silences with type creating - act(() => { - store.result.current.actions.addLocalItem({ - silence: silence, - id: "test", - type: "creating", - }) - - store.result.current.actions.addLocalItem({ - silence: silence2, - id: "test2", - type: "creating", - }) - }) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(2) - expect(store.result.current.localSilences["test"]["id"]).toEqual("test") - expect(store.result.current.localSilences["test2"]["id"]).toEqual("test2") - - // set a silence with the same id in active so it should be deleted (triggers updateLocalItems()) - act(() => - store.result.current.actions.setSilences({ - items: [silence, silence2], - itemsHash: { test: silence, test2: silence2 }, - itemsByState: { active: [silence, silence2] }, - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - - expect(store.result.current.localSilences["test2"]["id"]).toEqual("test2") - }) - - it("should add items with creating type and they should stay if they are set as an expired silence but not if its a pending one", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - actions: useSilencesActions(), - localSilences: useSilencesLocalItems(), - }), - { wrapper } - ) - - const silence = createFakeSilenceWithoutAlertFingerprint({ - status: { state: "creating" }, - }) - - act(() => { - store.result.current.actions.addLocalItem({ - silence: silence, - id: "test", - type: "creating", - }) - }) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - expect(store.result.current.localSilences["test"]["id"]).toEqual("test") - // set a silence with the same id in pending so it should not be deleted because - // local silence is creating (triggers updateLocalItems()) - - act(() => - store.result.current.actions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { expired: [silence] }, - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(1) - - act(() => - store.result.current.actions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { pending: [silence] }, - }) - ) - - expect(Object.keys(store.result.current.localSilences).length).toEqual(0) - }) -}) - describe("getMappingSilences", () => { - it("return all external silences referenced by silencedBy and all local silences with the same fingerprint which are not yet included", () => { + it("return all external silences referenced by silencedBy ", () => { const wrapper = ({ children }) => {children} const store = renderHook( () => ({ alertActions: useAlertsActions(), silenceActions: useSilencesActions(), - localSilences: useSilencesLocalItems(), }), { wrapper } ) @@ -280,28 +44,14 @@ describe("getMappingSilences", () => { act(() => store.result.current.silenceActions.setSilences({ items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, - }) - ) - // create local silence adding per attribute the id and the alert fingerprint - const silence2 = createFakeSilenceWith() - - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence2, - id: "local", - type: "local", }) ) // get mapping silences let mappingResult = null act(() => (mappingResult = store.result.current.silenceActions.getMappingSilences(alert))) - expect(mappingResult.length).toEqual(2) + expect(mappingResult.length).toEqual(1) expect(mappingResult.map((item) => item.id)).toContainEqual("external") - expect(mappingResult.map((item) => item.id)).toContainEqual("local") - expect(mappingResult.find((item) => item.id === "local").type).toEqual("local") }) it("return silences also when alert silencedBy is just a string", () => { @@ -324,327 +74,21 @@ describe("getMappingSilences", () => { counts: countAlerts([alert]), }) ) - // create local silence - const silence = createFakeSilenceWith({ id: "external" }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, - }) - ) - // get mapping silences - let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getMappingSilences(alert))) - expect(mappingResult.length).toEqual(1) - expect(mappingResult.map((item) => item.id)).toContainEqual("external") - }) - - it("ignores 'local silences' which are already included in silencedBy and exist as external silence", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - }), - { wrapper } - ) - - // create an alert with custom status - const status = createFakeAlertStatustWith({ - silencedBy: ["external", "externalAndLocal"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "123" }) - // set the alert - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // create external silences adding an id to the object - const silence = createFakeSilenceWith({ id: "external" }) - const silence2 = createFakeSilenceWith({ id: "externalAndLocal" }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [silence, silence2], - itemsHash: { external: silence, externalAndLocal: silence2 }, - itemsByState: { active: [silence, silence2] }, - }) - ) - // create local silence which already exists as external silence - const silence3 = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence3, - id: "externalAndLocal", - }) - ) - // get mapping silences - let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getMappingSilences(alert))) - expect(mappingResult.length).toEqual(2) - // checking type to be undefined means that the silence is not local - expect(mappingResult[0].type).toEqual(undefined) - expect(mappingResult[1].type).toEqual(undefined) - }) - - it("returns local silences when the id exists in silencedBy but it does not exist as external silence", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - }), - { wrapper } - ) - - // create an alert with custom status - const status = createFakeAlertStatustWith({ - silencedBy: ["external", "local"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "123" }) - // set the alert - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // create external silences adding an id to the object - const silence = createFakeSilenceWith({ id: "external" }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, - }) - ) - // create local silence which already exists as external silence - const silence2 = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence2, - id: "local", - type: "local", - }) - ) - // get mapping silences - let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getMappingSilences(alert))) - expect(mappingResult.length).toEqual(2) - // checking type to be undefined means that the silence is not local - expect(mappingResult[0].type).toEqual(undefined) - expect(mappingResult[1].type).toEqual("local") - }) -}) - -describe("updateLocalItems", () => { - it("removes local silences whose alert reference (defined by alertFingerprint) has in silencedBy the silence itself and a silence with same id exist also as external silences", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - savedLocalSilences: useSilencesLocalItems(), - savedAlerts: useAlertsItems(), - }), - { wrapper } - ) - - // create local silences - const silence = createFakeSilenceWith({ alertFingerprint: "12345" }) - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence, - id: "test1local", - type: "local", - }) - ) - const silence2 = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence2, - id: "test2local", - type: "local", - }) - ) - // check if the local silence are saved - expect(Object.keys(store.result.current.savedLocalSilences).length).toEqual(2) - // create an alert without any silencedBy so we just have the local silences - const status = createFakeAlertStatustWith({ - silencedBy: ["test1local"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "12345" }) - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // check if the alert is saved - expect(store.result.current.savedAlerts.length).toEqual(1) - - // trigger update local items by setting new external silences - const externalSilence = createFakeSilenceWith({ id: "test1local" }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [externalSilence], - itemsHash: { [externalSilence.id]: externalSilence }, - itemsByState: { active: [externalSilence] }, - }) - ) - // check local items - expect(Object.keys(store.result.current.savedLocalSilences).length).toEqual(1) - expect(store.result.current.savedLocalSilences["test2local"].id).toEqual("test2local") - }) - - it("keeps local silences if silence with same id does not exist yet in external silences", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - savedLocalSilences: useSilencesLocalItems(), - savedAlerts: useAlertsItems(), - }), - { wrapper } - ) - - // create local silences - const silence = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence, - id: "test1local", - type: "local", - }) - ) - const silence2 = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence2, - id: "test2local", - type: "local", - }) - ) - // check if the local silence are saved - expect(Object.keys(store.result.current.savedLocalSilences).length).toEqual(2) - // create an alert without any silencedBy so we just have the local silences - const status = createFakeAlertStatustWith({ - silencedBy: ["test1local"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "12345" }) - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // check if the alert is saved - expect(store.result.current.savedAlerts.length).toEqual(1) - // trigger update local items by setting new external silences - const externalSilence = createFakeSilenceWith({ - id: "different_id_then_test1local", - }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [externalSilence], - itemsHash: { [externalSilence.id]: externalSilence }, - itemsByState: { active: [externalSilence] }, - }) - ) - // check local items - expect(Object.keys(store.result.current.savedLocalSilences).length).toEqual(2) - expect(store.result.current.savedLocalSilences["test1local"].id).toEqual("test1local") - expect(store.result.current.savedLocalSilences["test2local"].id).toEqual("test2local") - }) -}) - -describe("getMappedState", () => { - it("retuns supressed (processing) if a local silence is found", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - }), - { wrapper } - ) - // create an alert with custom status - const status = createFakeAlertStatustWith({ - silencedBy: ["external"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "123" }) - // set the alert - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) // create extern silences adding an id to the object const silence = createFakeSilenceWith({ id: "external" }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, - }) - ) - // create local silence adding per attribute the id and the alert fingerprint - const silence2 = createFakeSilenceWith() - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence2, - id: "local", - type: "local", - }) - ) - // get mapping silences - let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getMappedState(alert))) - expect(mappingResult["type"]).toEqual("suppressed") - expect(mappingResult["isProcessing"]).toEqual(true) - }) - - it("retuns just the alert.status.state if no local silences found", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - }), - { wrapper } - ) - // create an alert with custom status - const status = createFakeAlertStatustWith({ - silencedBy: ["external"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "123" }) - // set the alert - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // create extern silences adding an id to the object - const silence = createFakeSilenceWith({ id: "external" }) act(() => store.result.current.silenceActions.setSilences({ items: [silence], - itemsHash: { external: silence }, - itemsByState: { active: [silence] }, }) ) + // get mapping silences let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getMappedState(alert))) - expect(mappingResult["type"]).toEqual(alert?.status?.state) - expect(mappingResult["isProcessing"]).toEqual(false) + act(() => (mappingResult = store.result.current.silenceActions.getMappingSilences(alert))) + expect(mappingResult.length).toEqual(1) + expect(mappingResult.map((item) => item.id)).toContainEqual("external") }) }) @@ -719,8 +163,6 @@ describe("getExpiredSilences", () => { act(() => store.result.current.silenceActions.setSilences({ items: [silence, silence2, silence3], - itemsHash: { test1: silence, test2: silence2, test3: silence3 }, - itemsByState: { expired: [silence, silence2, silence3] }, }) ) // get mapping silences @@ -732,62 +174,7 @@ describe("getExpiredSilences", () => { }) describe("getLatestMappingSilence", () => { - it("returns the silence with the latest endsAt timestamp when local", () => { - const wrapper = ({ children }) => {children} - const store = renderHook( - () => ({ - alertActions: useAlertsActions(), - silenceActions: useSilencesActions(), - }), - { wrapper } - ) - - // create an alert with custom status - const status = createFakeAlertStatustWith({ - silencedBy: ["external"], - }) - const alert = createFakeAlertWith({ status: status, fingerprint: "123" }) - // set the alert - act(() => - store.result.current.alertActions.setAlertsData({ - items: [alert], - counts: countAlerts([alert]), - }) - ) - // create extern silences adding an id to the object - const silence = createFakeSilenceWith({ - id: "external", - endsAt: "2023-06-21T15:17:28.327Z", - }) - const silence2 = createFakeSilenceWith({ - id: "external2", - endsAt: "2023-06-21T16:18:28.327Z", - }) - act(() => - store.result.current.silenceActions.setSilences({ - items: [silence, silence2], - itemsHash: { external: silence, external2: silence2 }, - itemsByState: { active: [silence, silence2] }, - }) - ) - // create local silence adding per attribute the id and the alert fingerprint - const silence3 = createFakeSilenceWith({ - endsAt: "2023-06-21T19:17:28.327Z", - }) - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence3, - id: "local", - type: "local", - }) - ) - // get mapping silences - let mappingResult = null - act(() => (mappingResult = store.result.current.silenceActions.getLatestMappingSilence(alert))) - expect(mappingResult.id).toEqual("local") - }) - - it("returns the silence with the latest endsAt timestamp when external", () => { + it("returns the silence with the latest endsAt timestamp ", () => { const wrapper = ({ children }) => {children} const store = renderHook( () => ({ @@ -821,21 +208,9 @@ describe("getLatestMappingSilence", () => { act(() => store.result.current.silenceActions.setSilences({ items: [silence, silence2], - itemsHash: { external: silence, external2: silence2 }, - itemsByState: { active: [silence, silence2] }, - }) - ) - // create local silence adding per attribute the id and the alert fingerprint - const silence3 = createFakeSilenceWith({ - endsAt: "2023-06-21T19:17:28.327Z", - }) - act(() => - store.result.current.silenceActions.addLocalItem({ - silence: silence3, - id: "local", - type: "local", }) ) + // get mapping silences let mappingResult = null act(() => (mappingResult = store.result.current.silenceActions.getLatestMappingSilence(alert))) diff --git a/apps/supernova/src/lib/queries/silencesQueries.js b/apps/supernova/src/lib/queries/silencesQueries.js deleted file mode 100644 index 62eb48868..000000000 --- a/apps/supernova/src/lib/queries/silencesQueries.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { sortSilencesByState } from "../utils" - -export const fetchSilences = async (endpoint) => { - try { - const response = await fetch(`${endpoint}/silences`) - - if (!response.ok) { - // Parse the error object from the response body - const errorObject = await response.json().catch(() => { - throw new Error(`Unexpected error: Unable to parse error response.`) - }) - - // Throw the error object directly - throw errorObject - } - - const items = await response.json() // Parse JSON data - - // Convert items to hash for easier access - const itemsHash = items.reduce((hash, silence) => { - hash[silence.id] = silence - return hash - }, {}) - - // Split items by state (active, pending, expired) - const itemsByState = sortSilencesByState(items) - - // Return the structured result - return { - silences: items, - silencesHash: itemsHash, - silencesByState: itemsByState, - } - } catch (error) { - console.error(error) - throw error // Let React Query handle the error - } -} diff --git a/apps/supernova/src/lib/utils.js b/apps/supernova/src/lib/utils.js index e33bdf5f9..ab0eecc7b 100644 --- a/apps/supernova/src/lib/utils.js +++ b/apps/supernova/src/lib/utils.js @@ -49,23 +49,6 @@ export const humanizeString = (value) => { return humanized } -// sort silences by state -// { -// active: [...], pending: [...], expired:[...], ... -// } -export const sortSilencesByState = (silences) => { - const sortedSilences = {} - - if (!silences || silences.length === 0) return {} - - silences.forEach((silences) => { - const state = silences.status?.state - if (!sortedSilences[state]) sortedSilences[state] = [] // init - sortedSilences[state].push(silences) - }) - return sortedSilences -} - // count alerts and create a map // { // global: { total: number, critical: number, ...},