From 7015d250b5db4599cda5138b2af20429514c6826 Mon Sep 17 00:00:00 2001 From: Lukasz Mierzwa Date: Mon, 3 Aug 2026 16:08:33 +0100 Subject: [PATCH] fix(ui): migrate mobx code to new version Latest mobx removes support for old decorators and other things, migrate code to the new way of mobxing. --- ui/src/Common/Alert.ts | 15 +- ui/src/Components/AlertAck/index.tsx | 3 +- ui/src/Components/AlertHistory/index.tsx | 9 +- .../AlertGrid/AlertGroup/Alert/AlertMenu.tsx | 13 +- .../Grid/AlertGrid/AlertGroup/Alert/index.tsx | 7 +- .../AlertGroup/GroupFooter/index.tsx | 4 +- .../AlertGroup/GroupHeader/GroupMenu.tsx | 8 +- .../AlertGroup/GroupHeader/index.tsx | 4 +- .../Grid/AlertGrid/AlertGroup/Silences.tsx | 4 +- .../Grid/AlertGrid/AlertGroup/index.tsx | 11 +- ui/src/Components/Grid/AlertGrid/Grid.tsx | 4 +- .../Grid/AlertGrid/GridLabelSelect.tsx | 8 +- ui/src/Components/Grid/AlertGrid/Swimlane.tsx | 4 +- .../ManagedSilence/DeleteSilence.tsx | 6 +- .../ManagedSilence/SilenceComment.tsx | 6 +- .../ManagedSilence/SilenceDetails.tsx | 4 +- ui/src/Components/ManagedSilence/index.tsx | 10 +- .../SilenceModal/Browser/MassDelete.tsx | 3 +- ui/src/Models/APITypes.ts | 6 + ui/src/Stores/AlertStore.ts | 846 ++++++++---------- ui/src/Stores/SilenceFormStore.ts | 757 +++++++--------- ui/src/e2e/stories.tsx | 12 +- 22 files changed, 808 insertions(+), 936 deletions(-) diff --git a/ui/src/Common/Alert.ts b/ui/src/Common/Alert.ts index 4e220c9c6..4164a7d72 100644 --- a/ui/src/Common/Alert.ts +++ b/ui/src/Common/Alert.ts @@ -1,4 +1,9 @@ -import type { AlertStateT, APIAlertT, APIAlertGroupT } from "Models/APITypes"; +import type { + AlertStateT, + APIAlertT, + APIAlertGroupT, + ReadOnly, +} from "Models/APITypes"; export interface VanillaAlertT { labels: { [key: string]: string }; @@ -15,8 +20,8 @@ export interface VanillaAlertT { } export const alertToJSON = ( - group: APIAlertGroupT, - alert: APIAlertT, + group: ReadOnly, + alert: ReadOnly, ): VanillaAlertT[] => { const alerts: VanillaAlertT[] = []; @@ -36,8 +41,8 @@ export const alertToJSON = ( startsAt: am.startsAt, generatorURL: am.source, status: { - inhibitedBy: am.inhibitedBy, - silencedBy: am.silencedBy, + inhibitedBy: [...am.inhibitedBy], + silencedBy: [...am.silencedBy], state: am.state, }, })) diff --git a/ui/src/Components/AlertAck/index.tsx b/ui/src/Components/AlertAck/index.tsx index 17bf3fb29..fa17f2947 100644 --- a/ui/src/Components/AlertAck/index.tsx +++ b/ui/src/Components/AlertAck/index.tsx @@ -14,6 +14,7 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama import type { APIAlertGroupT, AlertmanagerSilencePayloadT, + ReadOnly, } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import { @@ -37,7 +38,7 @@ interface PostResponseT { const AlertAck: FC<{ alertStore: AlertStore; silenceFormStore: SilenceFormStore; - group: APIAlertGroupT; + group: ReadOnly; }> = observer(({ alertStore, silenceFormStore, group }) => { const [clusters, setClusters] = useState([]); const [upstreams, setUpstreams] = useState([]); diff --git a/ui/src/Components/AlertHistory/index.tsx b/ui/src/Components/AlertHistory/index.tsx index dbcc4c4b7..328fd8bb2 100644 --- a/ui/src/Components/AlertHistory/index.tsx +++ b/ui/src/Components/AlertHistory/index.tsx @@ -7,6 +7,7 @@ import type { APIAlertGroupT, APIGridT, HistoryResponseT, + ReadOnly, } from "Models/APITypes"; import { useFetchAny, UpstreamT } from "Hooks/useFetchAny"; import { TooltipWrapper } from "Components/TooltipWrapper"; @@ -26,10 +27,10 @@ const GetUTCSeconds = (): number => { return (now.getTime() + now.getTimezoneOffset()) / 1000; }; -export const AlertHistory: FC<{ group: APIAlertGroupT; grid: APIGridT }> = ({ - group, - grid, -}) => { +export const AlertHistory: FC<{ + group: ReadOnly; + grid: ReadOnly; +}> = ({ group, grid }) => { const [ref, inView] = useInView({ triggerOnce: true }); const [lastUpdate, setLastUpdate] = useState(() => GetUTCSeconds()); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx index bcba37b0e..de170bc5b 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx @@ -17,6 +17,7 @@ import type { APIAlertT, APIAlertGroupT, APIAnnotationT, + ReadOnly, } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import { @@ -33,8 +34,8 @@ import { alertToJSON } from "Common/Alert"; const onSilenceClick = ( alertStore: AlertStore, silenceFormStore: SilenceFormStore, - group: APIAlertGroupT, - alert: APIAlertT, + group: ReadOnly, + alert: ReadOnly, ) => { const clusters: { [cluster: string]: string[] } = {}; Object.entries(alertStore.data.clustersWithoutReadOnly).forEach( @@ -61,8 +62,8 @@ interface MenuContentProps { y: number; floating: Ref | null; strategy: CSSProperties["position"]; - group: APIAlertGroupT; - alert: APIAlertT; + group: ReadOnly; + alert: ReadOnly; afterClick: () => void; alertStore: AlertStore; silenceFormStore: SilenceFormStore; @@ -173,8 +174,8 @@ const MenuContent = observer( ); interface AlertMenuProps { - group: APIAlertGroupT; - alert: APIAlertT; + group: ReadOnly; + alert: ReadOnly; alertStore: AlertStore; silenceFormStore: SilenceFormStore; setIsMenuOpen: (isOpen: boolean) => void; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.tsx index 1f134d9cc..19718f32c 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.tsx @@ -6,6 +6,7 @@ import type { APIAlertT, APIAlertGroupT, APIAlertmanagerStateT, + ReadOnly, } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -18,8 +19,8 @@ import { AlertMenu } from "./AlertMenu"; import { RenderSilence } from "../Silences"; const Alert: FC<{ - group: APIAlertGroupT; - alert: APIAlertT; + group: ReadOnly; + alert: ReadOnly; showReceiver: boolean; showOnlyExpandedAnnotations: boolean; afterUpdate: () => void; @@ -48,7 +49,7 @@ const Alert: FC<{ const silences: { [cluster: string]: { - alertmanager: APIAlertmanagerStateT; + alertmanager: ReadOnly; silences: string[]; }; } = {}; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.tsx index f32d14d49..a6ecc9b7f 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { observer } from "mobx-react-lite"; -import type { APIAlertGroupT } from "Models/APITypes"; +import type { APIAlertGroupT, ReadOnly } from "Models/APITypes"; import { StaticLabels } from "Common/Query"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -11,7 +11,7 @@ import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation"; import { RenderSilence } from "../Silences"; const GroupFooter: FC<{ - group: APIAlertGroupT; + group: ReadOnly; afterUpdate: () => void; alertStore: AlertStore; silenceFormStore: SilenceFormStore; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx index 034c032dc..2566d3f19 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx @@ -12,7 +12,7 @@ import { faShareSquare } from "@fortawesome/free-solid-svg-icons/faShareSquare"; import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash"; import { faWrench } from "@fortawesome/free-solid-svg-icons/faWrench"; -import type { APIAlertGroupT } from "Models/APITypes"; +import type { APIAlertGroupT, ReadOnly } from "Models/APITypes"; import { FormatAlertsQ } from "Stores/AlertStore"; import type { AlertStore } from "Stores/AlertStore"; import { @@ -28,7 +28,7 @@ import { MenuLink } from "Components/Grid/AlertGrid/AlertGroup/MenuLink"; const onSilenceClick = ( alertStore: AlertStore, silenceFormStore: SilenceFormStore, - group: APIAlertGroupT, + group: ReadOnly, ) => { const clusters: { [cluster: string]: string[] } = {}; Object.entries(alertStore.data.clustersWithoutReadOnly).forEach( @@ -56,7 +56,7 @@ const MenuContent: FC<{ y: number; floating: Ref | null; strategy: CSSProperties["position"]; - group: APIAlertGroupT; + group: ReadOnly; afterClick: () => void; alertStore: AlertStore; silenceFormStore: SilenceFormStore; @@ -146,7 +146,7 @@ const MenuContent: FC<{ ); const GroupMenu: FC<{ - group: APIAlertGroupT; + group: ReadOnly; alertStore: AlertStore; silenceFormStore: SilenceFormStore; themed: boolean; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/index.tsx index efbbbd48f..166e970b4 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/index.tsx @@ -1,6 +1,6 @@ import type { FC, MouseEvent } from "react"; -import type { APIAlertGroupT } from "Models/APITypes"; +import type { APIAlertGroupT, ReadOnly } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; import FilteringLabel from "Components/Labels/FilteringLabel"; @@ -13,7 +13,7 @@ import { GroupMenu } from "./GroupMenu"; const GroupHeader: FC<{ isCollapsed: boolean; setIsCollapsed: (isCollapsed: boolean) => void; - group: APIAlertGroupT; + group: ReadOnly; alertStore: AlertStore; silenceFormStore: SilenceFormStore; themedCounters: boolean; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.tsx index f9d81181b..2506c390b 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.tsx @@ -1,6 +1,6 @@ import { FC, memo } from "react"; -import type { APISilenceT } from "Models/APITypes"; +import type { APISilenceT, ReadOnly } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; import { ManagedSilence } from "Components/ManagedSilence"; @@ -19,7 +19,7 @@ const GetSilenceFromStore = ( alertStore: AlertStore, cluster: string, silenceID: string, -): APISilenceT | null => { +): ReadOnly | null => { const amSilences = alertStore.data.silences[cluster]; if (!amSilences) return null; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.tsx index d42952aed..f892f4f5f 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.tsx @@ -16,7 +16,12 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; import { faMinus } from "@fortawesome/free-solid-svg-icons/faMinus"; import { faEllipsisH } from "@fortawesome/free-solid-svg-icons/faEllipsisH"; -import type { APIGridT, APIAlertGroupT, AlertStateT } from "Models/APITypes"; +import type { + APIGridT, + APIAlertGroupT, + AlertStateT, + ReadOnly, +} from "Models/APITypes"; import type { Settings } from "Stores/Settings"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -48,8 +53,8 @@ const LoadButton: FC<{ }; interface AlertGroupProps { - grid: APIGridT; - group: APIAlertGroupT; + grid: ReadOnly; + group: ReadOnly; afterUpdate: () => void; alertStore: AlertStore; settingsStore: Settings; diff --git a/ui/src/Components/Grid/AlertGrid/Grid.tsx b/ui/src/Components/Grid/AlertGrid/Grid.tsx index 04b03b524..b72a462b1 100644 --- a/ui/src/Components/Grid/AlertGrid/Grid.tsx +++ b/ui/src/Components/Grid/AlertGrid/Grid.tsx @@ -27,7 +27,7 @@ import { faAngleDoubleDown } from "@fortawesome/free-solid-svg-icons/faAngleDoub import type { AlertStore } from "Stores/AlertStore"; import type { Settings } from "Stores/Settings"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; -import type { APIGridT } from "Models/APITypes"; +import type { APIGridT, ReadOnly } from "Models/APITypes"; import { useGrid } from "Hooks/useGrid"; import { ThemeContext } from "Components/Theme"; import { DefaultDetailsCollapseValue } from "./AlertGroup/DetailsToggle"; @@ -118,7 +118,7 @@ const Grid: FC<{ settingsStore: Settings; gridSizesConfig: SizeDetail[]; groupWidth: number; - grid: APIGridT; + grid: ReadOnly; outerPadding: number; paddingTop: number; zIndex: number; diff --git a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx index f23bc6c77..461b21d3e 100644 --- a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx +++ b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx @@ -18,7 +18,7 @@ import { faCaretDown } from "@fortawesome/free-solid-svg-icons/faCaretDown"; import type { AlertStore } from "Stores/AlertStore"; import type { Settings } from "Stores/Settings"; -import type { APIGridT } from "Models/APITypes"; +import type { APIGridT, ReadOnly } from "Models/APITypes"; import { StringToOption, OptionT } from "Common/Select"; import { DropdownSlide } from "Components/Animations/DropdownSlide"; import { ThemeContext } from "Components/Theme"; @@ -36,7 +36,7 @@ const NullContainer: FC = () => null; const GridLabelNameSelect: FC<{ alertStore: AlertStore; settingsStore: Settings; - grid: APIGridT; + grid: ReadOnly; onClose: () => void; }> = ({ alertStore, settingsStore, grid, onClose }) => { const loadOptions = ( @@ -94,7 +94,7 @@ const Dropdown: FC<{ strategy: CSSProperties["position"]; alertStore: AlertStore; settingsStore: Settings; - grid: APIGridT; + grid: ReadOnly; onClose: () => void; }> = ({ x, @@ -131,7 +131,7 @@ const Dropdown: FC<{ const GridLabelSelect: FC<{ alertStore: AlertStore; settingsStore: Settings; - grid: APIGridT; + grid: ReadOnly; }> = ({ alertStore, settingsStore, grid }) => { const [isVisible, setIsVisible] = useState(false); const hide = useCallback(() => setIsVisible(false), []); diff --git a/ui/src/Components/Grid/AlertGrid/Swimlane.tsx b/ui/src/Components/Grid/AlertGrid/Swimlane.tsx index 0084f931d..23b054707 100644 --- a/ui/src/Components/Grid/AlertGrid/Swimlane.tsx +++ b/ui/src/Components/Grid/AlertGrid/Swimlane.tsx @@ -5,7 +5,7 @@ import { faGrip } from "@fortawesome/free-solid-svg-icons/faGrip"; import type { AlertStore } from "Stores/AlertStore"; import type { Settings } from "Stores/Settings"; -import type { APIGridT } from "Models/APITypes"; +import type { APIGridT, ReadOnly } from "Models/APITypes"; import FilteringLabel from "Components/Labels/FilteringLabel"; import FilteringCounterBadge from "Components/Labels/FilteringCounterBadge"; import { TooltipWrapper } from "Components/TooltipWrapper"; @@ -15,7 +15,7 @@ import { GridLabelSelect } from "./GridLabelSelect"; interface SwimlaneProps { alertStore: AlertStore; settingsStore: Settings; - grid: APIGridT; + grid: ReadOnly; isExpanded: boolean; onToggle: (event: MouseEvent) => void; paddingTop: number; diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.tsx b/ui/src/Components/ManagedSilence/DeleteSilence.tsx index 9a023696e..b10dafdfe 100644 --- a/ui/src/Components/ManagedSilence/DeleteSilence.tsx +++ b/ui/src/Components/ManagedSilence/DeleteSilence.tsx @@ -9,7 +9,7 @@ import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; import { faRedo } from "@fortawesome/free-solid-svg-icons/faRedo"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; -import type { APISilenceT } from "Models/APITypes"; +import type { APISilenceT, ReadOnly } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; @@ -109,7 +109,7 @@ const DeleteSilenceModalContent: FC<{ alertStore: AlertStore; silenceFormStore: SilenceFormStore; cluster: string; - silence: APISilenceT; + silence: ReadOnly; onHide: () => void; }> = ({ alertStore, silenceFormStore, cluster, silence, onHide }) => { const [confirm, setConfirm] = useState(false); @@ -167,7 +167,7 @@ const DeleteSilence: FC<{ alertStore: AlertStore; silenceFormStore: SilenceFormStore; cluster: string; - silence: APISilenceT; + silence: ReadOnly; isUpper?: boolean; }> = ({ alertStore, silenceFormStore, cluster, silence, isUpper = false }) => { const [visible, setVisible] = useState(false); diff --git a/ui/src/Components/ManagedSilence/SilenceComment.tsx b/ui/src/Components/ManagedSilence/SilenceComment.tsx index 753ec02f0..2737fa68a 100644 --- a/ui/src/Components/ManagedSilence/SilenceComment.tsx +++ b/ui/src/Components/ManagedSilence/SilenceComment.tsx @@ -8,7 +8,7 @@ import { differenceInSeconds } from "date-fns"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash"; -import type { APISilenceT } from "Models/APITypes"; +import type { APISilenceT, ReadOnly } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import FilteringCounterBadge from "Components/Labels/FilteringCounterBadge"; import { ToggleIcon } from "Components/ToggleIcon"; @@ -16,7 +16,7 @@ import { DateFromNow } from "Components/DateFromNow"; import { StaticLabels } from "Common/Query"; const SilenceProgress: FC<{ - silence: APISilenceT; + silence: ReadOnly; }> = ({ silence }) => { const [now] = useState(() => new Date()); const diff = differenceInSeconds(parseISO(silence.endsAt), now); @@ -40,7 +40,7 @@ const SilenceProgress: FC<{ const SilenceComment: FC<{ cluster: string; - silence: APISilenceT; + silence: ReadOnly; alertCount: number; collapsed: boolean; collapseToggle: () => void; diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.tsx index cc621fe01..3ebd76f24 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.tsx @@ -17,7 +17,7 @@ import { faHome } from "@fortawesome/free-solid-svg-icons/faHome"; import { faFingerprint } from "@fortawesome/free-solid-svg-icons/faFingerprint"; import { faCopy } from "@fortawesome/free-solid-svg-icons/faCopy"; -import type { APISilenceT } from "Models/APITypes"; +import type { APISilenceT, ReadOnly } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore, MatcherToOperator } from "Stores/SilenceFormStore"; import { TooltipWrapper } from "Components/TooltipWrapper"; @@ -53,7 +53,7 @@ const SilenceIDCopyButton: FC<{ const SilenceDetails: FC<{ alertStore: AlertStore; silenceFormStore: SilenceFormStore; - silence: APISilenceT; + silence: ReadOnly; cluster: string; onEditSilence: () => void; isUpper?: boolean; diff --git a/ui/src/Components/ManagedSilence/index.tsx b/ui/src/Components/ManagedSilence/index.tsx index ffafc2ad3..ad1f01f7d 100644 --- a/ui/src/Components/ManagedSilence/index.tsx +++ b/ui/src/Components/ManagedSilence/index.tsx @@ -5,7 +5,11 @@ import { action } from "mobx"; import { parseISO } from "date-fns/parseISO"; import { getUnixTime } from "date-fns/getUnixTime"; -import type { APISilenceT, APIAlertmanagerUpstreamT } from "Models/APITypes"; +import type { + APISilenceT, + APIAlertmanagerUpstreamT, + ReadOnly, +} from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceComment } from "./SilenceComment"; @@ -14,7 +18,7 @@ import { SilenceDetails } from "./SilenceDetails"; const GetAlertmanager = ( alertStore: AlertStore, cluster: string, -): APIAlertmanagerUpstreamT => +): ReadOnly => alertStore.data.readWriteAlertmanagers .filter((u) => u.cluster === cluster) .slice(0, 1)[0]; @@ -31,7 +35,7 @@ const ManagedSilence: FC<{ cluster: string; alertCount: number; alertCountAlwaysVisible: boolean; - silence: APISilenceT; + silence: ReadOnly; alertStore: AlertStore; silenceFormStore: SilenceFormStore; isOpen?: boolean; diff --git a/ui/src/Components/SilenceModal/Browser/MassDelete.tsx b/ui/src/Components/SilenceModal/Browser/MassDelete.tsx index 3eb0810b9..dfaa161a6 100644 --- a/ui/src/Components/SilenceModal/Browser/MassDelete.tsx +++ b/ui/src/Components/SilenceModal/Browser/MassDelete.tsx @@ -17,6 +17,7 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; import type { APIAlertmanagerUpstreamT, APIManagedSilenceT, + ReadOnly, } from "Models/APITypes"; import type { AlertStore } from "Stores/AlertStore"; import type { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -207,7 +208,7 @@ const MassDeleteProgress: FC<{ const deleteSilence = async ( cluster: string, id: string, - ams: APIAlertmanagerUpstreamT[], + ams: ReadOnly[], ) => { let err = ""; for (const am of ams) { diff --git a/ui/src/Models/APITypes.ts b/ui/src/Models/APITypes.ts index d06691540..3161d6384 100644 --- a/ui/src/Models/APITypes.ts +++ b/ui/src/Models/APITypes.ts @@ -1,5 +1,11 @@ export type AlertStateT = "unprocessed" | "active" | "suppressed"; +export type ReadOnly = T extends (infer R)[] + ? readonly ReadOnly[] + : T extends object + ? { readonly [K in keyof T]: ReadOnly } + : T; + interface LabelT { name: string; value: string; diff --git a/ui/src/Stores/AlertStore.ts b/ui/src/Stores/AlertStore.ts index 3ae5e8ffe..2933740b2 100644 --- a/ui/src/Stores/AlertStore.ts +++ b/ui/src/Stores/AlertStore.ts @@ -1,4 +1,4 @@ -import { observable, action, computed, toJS } from "mobx"; +import { makeAutoObservable, observableRef, action, toJS } from "mobx"; import { throttle } from "es-toolkit"; @@ -9,6 +9,7 @@ import type { APIAlertsResponseT, APIAlertsResponseColorsT, APIGridT, + ReadOnly, APIAlertsResponseSilenceMapT, APIAlertsResponseUpstreamsT, APIAlertsResponseUpstreamsClusterMapT, @@ -118,478 +119,405 @@ function NewUnappliedFilter(raw: string): FilterT { }; } -interface AlertStoreFiltersT { - values: FilterT[]; - addFilter: (raw: string) => void; - removeFilter: (raw: string) => void; - replaceFilter: (oldRaw: string, newRaw: string) => void; - setFilters: (raws: string[]) => void; - setFilterValues: (v: FilterT[]) => void; - setWithoutLocation: (raws: string[]) => void; - applyAllFilters: () => void; +class AlertStoreFilters { + values: FilterT[] = []; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true, name: "API Filters" }); + } + + addFilter(raw: string) { + if (this.values.filter((f) => f.raw === raw).length === 0) { + this.values.push(NewUnappliedFilter(raw)); + UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); + } + } + + removeFilter(raw: string) { + if (this.values.filter((f) => f.raw === raw).length > 0) { + this.values = this.values.filter((f) => f.raw !== raw); + UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); + } + } + + replaceFilter(oldRaw: string, newRaw: string) { + const index = this.values.findIndex((e) => e.raw === oldRaw); + if (index >= 0) { + // first check if we would create a duplicated filter + if (this.values.findIndex((e) => e.raw === newRaw) >= 0) { + // we already have newRaw, simply drop oldRaw + this.removeFilter(oldRaw); + } else { + // no dups, continue with a swap + this.values[index] = NewUnappliedFilter(newRaw); + UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); + } + } else { + this.addFilter(newRaw); + } + } + + setFilters(raws: string[]) { + this.values = raws.map((raw) => NewUnappliedFilter(raw)); + UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); + } + + setFilterValues(v: FilterT[]) { + this.values = v; + } + + setWithoutLocation(raws: string[]) { + const filtersByRaw: { [key: string]: FilterT } = this.values.reduce( + function (map: { [key: string]: FilterT }, obj) { + map[toJS(obj.raw)] = toJS(obj); + return map; + }, + {}, + ); + this.values = raws.map((raw) => + filtersByRaw[raw] ? filtersByRaw[raw] : NewUnappliedFilter(raw), + ); + } + + applyAllFilters() { + for (let i = 0; i < this.values.length; i++) { + this.values[i].applied = true; + } + } } -interface AlertStoreDataT { - colors: APIAlertsResponseColorsT; - grids: APIGridT[]; - labelNames: string[]; - setLabelNames: (v: string[]) => void; - silences: APIAlertsResponseSilenceMapT; - upstreams: APIAlertsResponseUpstreamsT; - receivers: string[]; - readonly gridPadding: number; - getAlertmanagerByName: (name: string) => APIAlertmanagerUpstreamT | undefined; - isReadOnlyAlertmanager: (name: string) => boolean; - getClusterAlertmanagersWithoutReadOnly: (clusterID: string) => string[]; - readonly readOnlyAlertmanagers: APIAlertmanagerUpstreamT[]; - readonly readWriteAlertmanagers: APIAlertmanagerUpstreamT[]; - readonly clustersWithoutReadOnly: APIAlertsResponseUpstreamsClusterMapT; - getColorData: (name: string, value: string) => APILabelColorT | undefined; - setGrids: (g: APIGridT[]) => void; - setUpstreams: (u: APIAlertsResponseUpstreamsT) => void; - setClusters: (c: APIAlertsResponseUpstreamsClusterMapT) => void; - setSilences: (s: APIAlertsResponseSilenceMapT) => void; - setReceivers: (r: string[]) => void; - setColors: (c: APIAlertsResponseColorsT) => void; - readonly upstreamsWithErrors: APIAlertmanagerUpstreamT[]; -} - -interface AlertStoreInfoT { - authentication: { - enabled: boolean; - username: string; +class AlertStoreData { + colors: ReadOnly = {}; + grids: ReadOnly = []; + labelNames: ReadOnly = []; + silences: ReadOnly = {}; + upstreams: ReadOnly = { + counters: { total: 0, healthy: 0, failed: 0 }, + instances: [], + clusters: {}, }; - totalAlerts: number; - timestamp: string; - version: string; - upgradeReady: boolean; - upgradeNeeded: boolean; - isRetrying: boolean; - reloadNeeded: boolean; - setIsRetrying: () => void; - clearIsRetrying: () => void; - setUpgradeNeeded: (v: boolean) => void; - setUpgradeReady: (v: boolean) => void; - setReloadNeeded: (v: boolean) => void; - setTotalAlerts: (n: number) => void; - setAuthentication: (enabled: boolean, username: string) => void; - setVersion: (v: string) => void; - setTimestamp: (v: string) => void; + receivers: ReadOnly = []; + + constructor() { + makeAutoObservable( + this, + { + // all of these are replaced wholesale on every API response, + // so there's no need to deep convert them into observables + colors: observableRef, + grids: observableRef, + labelNames: observableRef, + silences: observableRef, + upstreams: observableRef, + receivers: observableRef, + }, + { autoBind: true, name: "API Response data" }, + ); + } + + get gridPadding(): number { + return this.grids.filter((g) => g.labelName !== "").length > 0 ? 5 : 0; + } + + getAlertmanagerByName( + name: string, + ): ReadOnly | undefined { + return this.upstreams.instances.find((am) => am.name === name); + } + + isReadOnlyAlertmanager(name: string): boolean { + return this.readOnlyAlertmanagers.map((am) => am.name).includes(name); + } + + getClusterAlertmanagersWithoutReadOnly(clusterID: string): string[] { + return this.clustersWithoutReadOnly[clusterID] || []; + } + + get readOnlyAlertmanagers(): ReadOnly[] { + return this.upstreams.instances.filter((am) => am.readonly === true); + } + + get readWriteAlertmanagers(): ReadOnly[] { + return this.upstreams.instances + .filter((am) => am.readonly === false) + .map((am) => + Object.assign({}, am, { + clusterMembers: am.clusterMembers.filter( + (m) => this.isReadOnlyAlertmanager(m) === false, + ), + }), + ); + } + + get clustersWithoutReadOnly(): APIAlertsResponseUpstreamsClusterMapT { + const unhealthy = this.upstreams.instances + .filter((upstream) => upstream.error !== "") + .map((upstream) => upstream.name); + const clusters: APIAlertsResponseUpstreamsClusterMapT = {}; + for (const clusterID of Object.keys(this.upstreams.clusters)) { + const members = this.upstreams.clusters[clusterID].filter( + (member) => this.isReadOnlyAlertmanager(member) === false, + ); + if (members.length > 0) { + clusters[clusterID] = [ + ...members.filter((member) => !unhealthy.includes(member)), + ...members.filter((member) => unhealthy.includes(member)), + ]; + } + } + return clusters; + } + + getColorData( + name: string, + value: string, + ): ReadOnly | undefined { + if (this.colors[name] !== undefined) { + return this.colors[name][value]; + } + } + + setGrids(g: ReadOnly) { + this.grids = g; + } + + setUpstreams(u: ReadOnly) { + this.upstreams = u; + } + + setClusters(c: ReadOnly) { + this.upstreams = { ...this.upstreams, clusters: c }; + } + + setSilences(s: ReadOnly) { + this.silences = s; + } + + setReceivers(r: ReadOnly) { + this.receivers = r; + } + + setColors(c: ReadOnly) { + this.colors = c; + } + + setLabelNames(v: ReadOnly) { + this.labelNames = v; + } + + get upstreamsWithErrors(): ReadOnly[] { + const unhealthy: ReadOnly[] = []; + for (const clusterID of Object.keys(this.upstreams.clusters)) { + const members = this.upstreams.instances.filter( + (upstream) => upstream.cluster === clusterID, + ); + if ( + members.length > 0 && + members.filter((upstream) => upstream.error === "").length === 0 + ) { + unhealthy.push(...members); + } + } + return unhealthy; + } } -interface AlertStoreSettingsT { - values: APISettingsT; - setValues: (v: APISettingsT) => void; +class AlertStoreInfo { + authentication = { + enabled: false as boolean, + username: "", + }; + totalAlerts = 0; + version = "unknown"; + timestamp = ""; + upgradeReady = false; + upgradeNeeded = false; + isRetrying = false; + reloadNeeded = false; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true, name: "API response info" }); + } + + setIsRetrying() { + this.isRetrying = true; + } + + clearIsRetrying() { + this.isRetrying = false; + } + + setUpgradeNeeded(v: boolean) { + this.upgradeNeeded = v; + } + + setUpgradeReady(v: boolean) { + this.upgradeReady = v; + } + + setReloadNeeded(v: boolean) { + this.reloadNeeded = v; + } + + setTotalAlerts(n: number) { + this.totalAlerts = n; + } + + setAuthentication(enabled: boolean, username: string) { + this.authentication.enabled = enabled; + this.authentication.username = username; + } + + setVersion(v: string) { + this.version = v; + } + + setTimestamp(v: string) { + this.timestamp = v; + } } -interface AlertStoreStatusT { - value: symbol; - lastUpdateAt: number | Date; - error: null | string; - stopped: boolean; - paused: boolean; - setIdle: () => void; - setFetching: () => void; - setProcessing: () => void; - setFailure: (err: string) => void; - pause: () => void; - resume: () => void; - togglePause: () => void; - stop: () => void; - setError: (e: null | string) => void; +class AlertStoreSettings { + values: ReadOnly = { + annotationsDefaultHidden: false as boolean, + annotationsHidden: [] as string[], + annotationsVisible: [] as string[], + annotationsEnableHTML: false as boolean, + sorting: { + grid: { + order: "startsAt", + reverse: false as boolean, + label: "alertname", + }, + valueMapping: {}, + }, + silenceForm: { + strip: { + labels: [] as string[], + }, + defaultAlertmanagers: [] as string[], + }, + alertAcknowledgement: { + enabled: false as boolean, + durationSeconds: 900, + author: "karma / author missing", + comment: "ACK! This alert was acknowledged using karma", + }, + historyEnabled: true, + gridGroupLimit: 40, + labels: {}, + }; + + constructor() { + makeAutoObservable( + this, + { values: observableRef }, + { autoBind: true, name: "Global settings" }, + ); + } + + setValues(v: ReadOnly) { + this.values = v; + } } -interface AlertStoreUIT { - isIdle: boolean; - setIsIdle: (val: boolean) => void; - gridGroupLimits: { [key: string]: { [val: string]: number } }; - setGridGroupLimit: (key: string, val: string, limit: number) => void; - groupAlertLimits: { [gid: string]: number }; - setGroupAlertLimit: (gid: string, limit: number) => void; - purgeGroupAlertLimits: (knownGids: string[]) => void; +class AlertStoreStatus { + value: symbol = AlertStoreStatuses.Idle; + lastUpdateAt: number | Date = 0; + error: null | string = null; + stopped = false; + paused = false; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true, name: "Store status" }); + } + + setIdle() { + this.value = AlertStoreStatuses.Idle; + this.error = null; + this.lastUpdateAt = new Date(); + } + + setFetching() { + this.value = AlertStoreStatuses.Fetching; + } + + setProcessing() { + this.value = AlertStoreStatuses.Processing; + this.error = null; + } + + setFailure(err: string) { + this.value = AlertStoreStatuses.Failure; + this.error = err; + this.lastUpdateAt = new Date(); + } + + pause() { + this.paused = true; + } + + resume() { + this.paused = this.stopped ? true : false; + } + + togglePause() { + this.paused = this.stopped ? true : !this.paused; + } + + stop() { + this.paused = true; + this.stopped = true; + } + + setError(e: null | string) { + this.error = e; + } +} + +class AlertStoreUI { + isIdle = false; + gridGroupLimits: { [key: string]: { [val: string]: number } } = {}; + groupAlertLimits: { [gid: string]: number } = {}; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + setIsIdle(val: boolean) { + this.isIdle = val; + } + + setGridGroupLimit(key: string, val: string, limit: number) { + this.gridGroupLimits = { + [key]: { ...this.gridGroupLimits[key], [val]: limit }, + }; + } + + setGroupAlertLimit(gid: string, limit: number) { + this.groupAlertLimits[gid] = limit; + } + + purgeGroupAlertLimits(knownGids: string[]) { + const newLimits: { [gid: string]: number } = {}; + Object.entries(this.groupAlertLimits) + .filter(([gid, _]) => knownGids.includes(gid)) + .forEach(([gid, limit]) => { + newLimits[gid] = limit; + }); + this.groupAlertLimits = newLimits; + } } class AlertStore { - filters: AlertStoreFiltersT; - data: AlertStoreDataT; - info: AlertStoreInfoT; - settings: AlertStoreSettingsT; - status: AlertStoreStatusT; - ui: AlertStoreUIT; + filters = new AlertStoreFilters(); + data = new AlertStoreData(); + info = new AlertStoreInfo(); + settings = new AlertStoreSettings(); + status = new AlertStoreStatus(); + ui = new AlertStoreUI(); constructor(initialFilters: null | string[]) { - this.filters = observable( - { - values: [] as FilterT[], - addFilter(raw: string) { - if (this.values.filter((f) => f.raw === raw).length === 0) { - this.values.push(NewUnappliedFilter(raw)); - UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); - } - }, - removeFilter(raw: string) { - if (this.values.filter((f) => f.raw === raw).length > 0) { - this.values = this.values.filter((f) => f.raw !== raw); - UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); - } - }, - replaceFilter(oldRaw: string, newRaw: string) { - const index = this.values.findIndex((e) => e.raw === oldRaw); - if (index >= 0) { - // first check if we would create a duplicated filter - if (this.values.findIndex((e) => e.raw === newRaw) >= 0) { - // we already have newRaw, simply drop oldRaw - this.removeFilter(oldRaw); - } else { - // no dups, continue with a swap - this.values[index] = NewUnappliedFilter(newRaw); - UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); - } - } else { - this.addFilter(newRaw); - } - }, - setFilters(raws: string[]) { - this.values = raws.map((raw) => NewUnappliedFilter(raw)); - UpdateLocationSearch({ q: this.values.map((f) => f.raw) }); - }, - setFilterValues(v: FilterT[]) { - this.values = v; - }, - setWithoutLocation(raws: string[]) { - const filtersByRaw: { [key: string]: FilterT } = this.values.reduce( - function (map: { [key: string]: FilterT }, obj) { - map[toJS(obj.raw)] = toJS(obj); - return map; - }, - {}, - ); - this.values = raws.map((raw) => - filtersByRaw[raw] ? filtersByRaw[raw] : NewUnappliedFilter(raw), - ); - }, - applyAllFilters() { - for (let i = 0; i < this.values.length; i++) { - this.values[i].applied = true; - } - }, - }, - { - addFilter: action.bound, - removeFilter: action.bound, - replaceFilter: action.bound, - setFilters: action.bound, - setFilterValues: action.bound, - setWithoutLocation: action.bound, - applyAllFilters: action.bound, - }, - { name: "API Filters" }, - ); - - this.data = observable( - { - colors: {} as APIAlertsResponseColorsT, - grids: [] as APIGridT[], - labelNames: [] as string[], - setLabelNames(v: string[]) { - this.labelNames = v; - }, - silences: {} as APIAlertsResponseSilenceMapT, - upstreams: { - counters: { total: 0, healthy: 0, failed: 0 }, - instances: [], - clusters: {}, - } as APIAlertsResponseUpstreamsT, - receivers: [] as string[], - get gridPadding(): number { - return this.grids.filter((g) => g.labelName !== "").length > 0 - ? 5 - : 0; - }, - getAlertmanagerByName( - name: string, - ): APIAlertmanagerUpstreamT | undefined { - return this.upstreams.instances.find((am) => am.name === name); - }, - isReadOnlyAlertmanager(name: string): boolean { - return this.readOnlyAlertmanagers.map((am) => am.name).includes(name); - }, - getClusterAlertmanagersWithoutReadOnly(clusterID: string): string[] { - return this.clustersWithoutReadOnly[clusterID] || []; - }, - get readOnlyAlertmanagers(): APIAlertmanagerUpstreamT[] { - return this.upstreams.instances.filter((am) => am.readonly === true); - }, - get readWriteAlertmanagers(): APIAlertmanagerUpstreamT[] { - return this.upstreams.instances - .filter((am) => am.readonly === false) - .map((am) => - Object.assign({}, am, { - clusterMembers: am.clusterMembers.filter( - (m) => this.isReadOnlyAlertmanager(m) === false, - ), - }), - ); - }, - get clustersWithoutReadOnly(): APIAlertsResponseUpstreamsClusterMapT { - const unhealthy = this.upstreams.instances - .filter((upstream) => upstream.error !== "") - .map((upstream) => upstream.name); - const clusters: APIAlertsResponseUpstreamsClusterMapT = {}; - for (const clusterID of Object.keys(this.upstreams.clusters)) { - const members = this.upstreams.clusters[clusterID].filter( - (member) => this.isReadOnlyAlertmanager(member) === false, - ); - if (members.length > 0) { - clusters[clusterID] = [ - ...members.filter((member) => !unhealthy.includes(member)), - ...members.filter((member) => unhealthy.includes(member)), - ]; - } - } - return clusters; - }, - getColorData(name: string, value: string): APILabelColorT | undefined { - if (this.colors[name] !== undefined) { - return this.colors[name][value]; - } - }, - setGrids(g: APIGridT[]) { - this.grids = g; - }, - setUpstreams(u: APIAlertsResponseUpstreamsT) { - this.upstreams = u; - }, - setClusters(c: APIAlertsResponseUpstreamsClusterMapT) { - this.upstreams.clusters = c; - }, - setSilences(s: APIAlertsResponseSilenceMapT) { - this.silences = s; - }, - setReceivers(r: string[]) { - this.receivers = r; - }, - setColors(c: APIAlertsResponseColorsT) { - this.colors = c; - }, - get upstreamsWithErrors(): APIAlertmanagerUpstreamT[] { - const unhealthy: APIAlertmanagerUpstreamT[] = []; - for (const clusterID of Object.keys(this.upstreams.clusters)) { - const members = this.upstreams.instances.filter( - (upstream) => upstream.cluster === clusterID, - ); - if ( - members.length > 0 && - members.filter((upstream) => upstream.error === "").length === 0 - ) { - unhealthy.push(...members); - } - } - return unhealthy; - }, - }, - { - gridPadding: computed, - readOnlyAlertmanagers: computed, - readWriteAlertmanagers: computed, - clustersWithoutReadOnly: computed, - setGrids: action.bound, - setUpstreams: action.bound, - setClusters: action.bound, - setSilences: action.bound, - setReceivers: action.bound, - setColors: action.bound, - setLabelNames: action.bound, - }, - { name: "API Response data" }, - ); - - this.info = observable( - { - authentication: { - enabled: false as boolean, - username: "", - }, - totalAlerts: 0, - version: "unknown", - timestamp: "", - upgradeReady: false as boolean, - upgradeNeeded: false as boolean, - isRetrying: false as boolean, - reloadNeeded: false as boolean, - setIsRetrying() { - this.isRetrying = true; - }, - clearIsRetrying() { - this.isRetrying = false; - }, - setUpgradeNeeded(v: boolean) { - this.upgradeNeeded = v; - }, - setUpgradeReady(v: boolean) { - this.upgradeReady = v; - }, - setReloadNeeded(v: boolean) { - this.reloadNeeded = v; - }, - setTotalAlerts(n: number) { - this.totalAlerts = n; - }, - setAuthentication(enabled: boolean, username: string) { - this.authentication.enabled = enabled; - this.authentication.username = username; - }, - setVersion(v: string) { - this.version = v; - }, - setTimestamp(v: string) { - this.timestamp = v; - }, - }, - { - setIsRetrying: action.bound, - clearIsRetrying: action.bound, - setReloadNeeded: action.bound, - setUpgradeNeeded: action.bound, - setTotalAlerts: action.bound, - setAuthentication: action.bound, - setVersion: action.bound, - setTimestamp: action.bound, - }, - { name: "API response info" }, - ); - - this.settings = observable( - { - values: { - annotationsDefaultHidden: false as boolean, - annotationsHidden: [] as string[], - annotationsVisible: [] as string[], - annotationsEnableHTML: false as boolean, - sorting: { - grid: { - order: "startsAt", - reverse: false as boolean, - label: "alertname", - }, - valueMapping: {}, - }, - silenceForm: { - strip: { - labels: [] as string[], - }, - defaultAlertmanagers: [] as string[], - }, - alertAcknowledgement: { - enabled: false as boolean, - durationSeconds: 900, - author: "karma / author missing", - comment: "ACK! This alert was acknowledged using karma", - }, - historyEnabled: true, - gridGroupLimit: 40, - labels: {}, - } as APISettingsT, - setValues(v: APISettingsT) { - this.values = v; - }, - }, - { - setValues: action.bound, - }, - { - name: "Global settings", - }, - ); - - this.status = observable( - { - value: AlertStoreStatuses.Idle, - lastUpdateAt: 0 as number | Date, - error: null as null | string, - stopped: false as boolean, - paused: false as boolean, - setIdle() { - this.value = AlertStoreStatuses.Idle; - this.error = null; - this.lastUpdateAt = new Date(); - }, - setFetching() { - this.value = AlertStoreStatuses.Fetching; - }, - setProcessing() { - this.value = AlertStoreStatuses.Processing; - this.error = null; - }, - setFailure(err: string) { - this.value = AlertStoreStatuses.Failure; - this.error = err; - this.lastUpdateAt = new Date(); - }, - pause() { - this.paused = true; - }, - resume() { - this.paused = this.stopped ? true : false; - }, - togglePause() { - this.paused = this.stopped ? true : !this.paused; - }, - stop() { - this.paused = true; - this.stopped = true; - }, - setError(e: null | string) { - this.error = e; - }, - }, - { - setIdle: action, - setFetching: action, - setProcessing: action, - setFailure: action, - pause: action.bound, - resume: action.bound, - togglePause: action.bound, - stop: action.bound, - setError: action.bound, - }, - { name: "Store status" }, - ); - - this.ui = observable( - { - isIdle: false as boolean, - setIsIdle(val: boolean) { - this.isIdle = val; - }, - gridGroupLimits: {} as { [key: string]: { [val: string]: number } }, - setGridGroupLimit(key: string, val: string, limit: number) { - this.gridGroupLimits = { - [key]: { ...this.gridGroupLimits[key], [val]: limit }, - }; - }, - groupAlertLimits: {} as { [gid: string]: number }, - setGroupAlertLimit(gid: string, limit: number) { - this.groupAlertLimits[gid] = limit; - }, - purgeGroupAlertLimits(knownGids: string[]) { - const newLimits: { [gid: string]: number } = {}; - Object.entries(this.groupAlertLimits) - .filter(([gid, _]) => knownGids.includes(gid)) - .forEach(([gid, limit]) => { - newLimits[gid] = limit; - }); - this.groupAlertLimits = newLimits; - }, - }, - { - setIsIdle: action.bound, - setGridGroupLimit: action.bound, - setGroupAlertLimit: action.bound, - }, - ); - if (initialFilters !== null) this.filters.setFilters(initialFilters); } diff --git a/ui/src/Stores/SilenceFormStore.ts b/ui/src/Stores/SilenceFormStore.ts index a7dbcdc42..cd5a28993 100644 --- a/ui/src/Stores/SilenceFormStore.ts +++ b/ui/src/Stores/SilenceFormStore.ts @@ -1,4 +1,4 @@ -import { observable, action, computed } from "mobx"; +import { makeAutoObservable, actionBound } from "mobx"; import { parseISO } from "date-fns/parseISO"; import { addHours } from "date-fns/addHours"; @@ -14,6 +14,7 @@ import type { APIAlertmanagerUpstreamT, AlertmanagerSilencePayloadT, AlertmanagerSilenceMatcherT, + ReadOnly, } from "Models/APITypes"; import { StringToOption, OptionT, MultiValueOptionT } from "Common/Select"; import { QueryOperators } from "Common/Query"; @@ -70,12 +71,12 @@ const MatcherToOperator = ( }; const AlertmanagerClustersToOption = (clusterDict: { - [key: string]: string[]; + [key: string]: ReadOnly; }): MultiValueOptionT[] => Object.entries(clusterDict).map(([clusterID, clusterMembers]) => ({ label: clusterMembers.length > 1 ? `Cluster: ${clusterID}` : clusterMembers[0], - value: clusterMembers, + value: [...clusterMembers], })); export const EscapeRegex = (v: string): string => { @@ -87,8 +88,8 @@ const UnescapeRegex = (v: string): string => { }; const MatchersFromGroup = ( - group: APIAlertGroupT, - stripLabels: string[], + group: ReadOnly, + stripLabels: ReadOnly, onlyActive?: boolean, ): MatcherWithIDT[] => { const matchers: MatcherWithIDT[] = []; @@ -120,9 +121,9 @@ const MatchersFromGroup = ( }; const MatchersFromAlerts = ( - group: APIAlertGroupT, - stripLabels: string[], - alerts: APIAlertT[], + group: ReadOnly, + stripLabels: ReadOnly, + alerts: ReadOnly, ): MatcherWithIDT[] => { const matchers: MatcherWithIDT[] = []; @@ -275,18 +276,41 @@ const UnpackRegexMatcherValues = (isRegex: boolean, value: string) => { type SilenceFormTabT = "editor" | "browser"; type SilenceFormStageT = "form" | "preview" | "submit"; -interface SilenceFormStoreToggleT { - visible: boolean; - blurred: boolean; - toggle: () => void; - hide: () => void; - show: () => void; - setBlur: (val: boolean) => void; +class SilenceFormStoreToggle { + visible = false; + blurred = false; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + toggle() { + this.visible = !this.visible; + } + + hide() { + this.visible = false; + } + + show() { + this.visible = true; + } + + setBlur(val: boolean) { + this.blurred = val; + } } -interface SilenceFormStoreTabT { - current: SilenceFormTabT; - setTab: (value: SilenceFormTabT) => void; +class SilenceFormStoreTab { + current: SilenceFormTabT = "editor"; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + setTab(value: SilenceFormTabT) { + this.current = value; + } } interface DurationT { @@ -295,415 +319,304 @@ interface DurationT { minutes: number; } -interface SilenceFormStoreDataT { - currentStage: SilenceFormStageT; - wasValidated: boolean; - silenceID: null | undefined | string; - alertmanagers: MultiValueOptionT[]; - matchers: MatcherWithIDT[]; - startsAt: Date; - endsAt: Date; - comment: string; - author: string; - requestsByCluster: { [key: string]: ClusterRequestT }; - autofillMatchers: boolean; - resetInputs: boolean; - readonly toBase64: string; - fromBase64: (s: string) => boolean; - readonly isValid: boolean; - resetStartEnd: () => void; - resetProgress: () => void; - resetSilenceID: () => void; - setSilenceID: (id: string | null) => void; - setAlertmanagers: (val: MultiValueOptionT[]) => void; - setAutofillMatchers: (v: boolean) => void; - setResetInputs: (v: boolean) => void; - setStage: (val: SilenceFormStageT) => void; - setMatchers: (m: MatcherWithIDT[]) => void; - addEmptyMatcher: () => void; - addMatcherWithID: (m: MatcherWithIDT) => void; - deleteMatcher: (id: string) => void; - fillMatchersFromGroup: ( - group: APIAlertGroupT, - stripLabels: string[], +class SilenceFormStoreData { + currentStage: SilenceFormStageT = "form"; + wasValidated = false; + silenceID: null | undefined | string = null; + alertmanagers: MultiValueOptionT[] = []; + matchers: MatcherWithIDT[] = []; + startsAt = new Date(); + endsAt = addHours(new Date(), 1); + comment = ""; + author = ""; + requestsByCluster: { [key: string]: ClusterRequestT } = {}; + autofillMatchers = true; + resetInputs = true; + + constructor() { + makeAutoObservable( + this, + { + // called from an autorun in AlertManagerInput, autoAction wouldn't + // create an action context there and trip enforceActions + setAlertmanagers: actionBound, + }, + { autoBind: true, name: "Silence form store" }, + ); + } + + get toBase64() { + const json = JSON.stringify({ + am: this.alertmanagers, + m: this.matchers.map((m: MatcherWithIDT) => ({ + n: m.name, + r: m.isRegex, + e: m.isEqual, + v: m.values.map((v) => v.value), + })), + d: differenceInMinutes(this.endsAt, this.startsAt), + c: this.comment, + }); + return window.btoa(json); + } + + fromBase64(s: string): boolean { + let parsed: SilenceFormDataFromBase64; + try { + parsed = JSON.parse(window.atob(s)); + } catch (error) { + console.error(`Failed to parse JSON: ${error}`); + return false; + } + + const matchers: MatcherWithIDT[] = []; + parsed.m.forEach((m: SimplifiedMatcherT) => { + const matcher = NewEmptyMatcher(); + matcher.name = m.n; + matcher.isRegex = m.r; + matcher.isEqual = m.e; + matcher.values = m.v.map((v) => StringToOption(v)); + matchers.push(matcher); + }); + + if (matchers.length > 0) { + this.alertmanagers = parsed.am; + this.matchers = matchers; + + this.startsAt = new Date(); + this.endsAt = addMinutes(this.startsAt, parsed.d); + this.comment = parsed.c; + + this.silenceID = null; + this.autofillMatchers = false; + this.resetInputs = false; + return true; + } + + return false; + } + + get isValid() { + if (this.alertmanagers.length === 0) return false; + if (this.matchers.length === 0) return false; + if ( + this.matchers.filter( + (m) => + m.name === "" || + m.values.length === 0 || + m.values.filter((v) => v.value === "").length > 0, + ).length > 0 + ) + return false; + if (this.comment === "") return false; + if (this.author === "") return false; + return true; + } + + resetStartEnd() { + this.startsAt = new Date(); + this.endsAt = addHours(new Date(), 1); + } + + resetProgress() { + this.currentStage = "form"; + this.wasValidated = false; + } + + resetSilenceID() { + this.silenceID = null; + } + + setSilenceID(id: string | null) { + this.silenceID = id; + } + + setAlertmanagers(val: MultiValueOptionT[]) { + this.alertmanagers = val; + } + + setAutofillMatchers(v: boolean) { + this.autofillMatchers = v; + } + + setResetInputs(v: boolean) { + this.resetInputs = v; + } + + setStage(val: SilenceFormStageT) { + this.currentStage = val; + } + + setMatchers(m: MatcherWithIDT[]) { + this.matchers = m; + } + + // append a new empty matcher to the list + addEmptyMatcher() { + this.matchers.push(NewEmptyMatcher()); + } + + addMatcherWithID(m: MatcherWithIDT) { + this.matchers.push(m); + } + + deleteMatcher(id: string) { + // only delete matchers if we have more than 1 + if (this.matchers.length > 1) { + this.matchers = this.matchers.filter((m) => m.id !== id); + } + } + + // if alerts argument is not passed all group alerts will be used + fillMatchersFromGroup( + group: ReadOnly, + stripLabels: ReadOnly, alertmanagers: MultiValueOptionT[], - alerts?: APIAlertT[], - ) => void; - fillFormFromSilence: ( - alertmanager: APIAlertmanagerUpstreamT, - silence: AlertmanagerSilencePayloadT, - ) => void; - setAuthor: (a: string) => void; - setComment: (c: string) => void; - verifyStarEnd: () => void; - setStart: (startsAt: Date) => void; - setEnd: (endsAt: Date) => void; - incStart: (minutes: number) => void; - decStart: (minutes: number) => void; - incEnd: (minutes: number) => void; - decEnd: (minutes: number) => void; - setWasValidated: (v: boolean) => void; - setRequestsByCluster: (val: { [key: string]: ClusterRequestT }) => void; - setRequestsByClusterUpdate: ( - key: string, - v: Partial, - ) => void; - readonly toAlertmanagerPayload: AlertmanagerSilencePayloadT; - readonly toDuration: DurationT; + alerts?: ReadOnly, + ) { + this.alertmanagers = alertmanagers; + + this.matchers = alerts + ? MatchersFromAlerts(group, stripLabels, alerts) + : MatchersFromGroup(group, stripLabels); + // ensure that silenceID is nulled, since it's used to edit silences + // and this is used to silence groups + this.silenceID = null; + // disable matcher autofill + this.autofillMatchers = false; + // disable alertmanager input reset + this.resetInputs = false; + } + + fillFormFromSilence( + alertmanager: ReadOnly, + silence: ReadOnly, + ) { + this.silenceID = silence.id; + + this.alertmanagers = AlertmanagerClustersToOption({ + [alertmanager.cluster]: alertmanager.clusterMembers, + }); + + const matchers: MatcherWithIDT[] = []; + for (const m of silence.matchers) { + const matcher = NewEmptyMatcher(); + matcher.name = m.name; + matcher.values = UnpackRegexMatcherValues(m.isRegex, m.value); + matcher.isRegex = m.isRegex; + matcher.isEqual = m.isEqual === false ? false : true; + matchers.push(matcher); + } + this.matchers = matchers; + + this.startsAt = parseISO(silence.startsAt); + this.endsAt = parseISO(silence.endsAt); + this.comment = silence.comment; + this.author = silence.createdBy; + + // disable matcher autofill + this.autofillMatchers = false; + } + + setAuthor(a: string) { + this.author = a; + } + + setComment(c: string) { + this.comment = c; + } + + verifyStarEnd() { + const now = new Date(); + now.setSeconds(0); + if (this.startsAt < now) { + this.startsAt = now; + } + + if (this.endsAt <= this.startsAt) { + this.endsAt = addMinutes(this.startsAt, 1); + } + } + + setStart(startsAt: Date) { + this.startsAt = startsAt; + } + + setEnd(endsAt: Date) { + this.endsAt = endsAt; + } + + incStart(minutes: number) { + this.startsAt = addMinutes(this.startsAt, minutes); + this.verifyStarEnd(); + } + + decStart(minutes: number) { + this.startsAt = subMinutes(this.startsAt, minutes); + this.verifyStarEnd(); + } + + incEnd(minutes: number) { + this.endsAt = addMinutes(this.endsAt, minutes); + this.verifyStarEnd(); + } + + decEnd(minutes: number) { + this.endsAt = subMinutes(this.endsAt, minutes); + this.verifyStarEnd(); + } + + setWasValidated(v: boolean) { + this.wasValidated = v; + } + + setRequestsByCluster(val: { [key: string]: ClusterRequestT }) { + this.requestsByCluster = val; + } + + setRequestsByClusterUpdate(key: string, v: Partial) { + this.requestsByCluster[key] = { + ...this.requestsByCluster[key], + ...v, + }; + } + + get toAlertmanagerPayload() { + const startsAt = new Date(this.startsAt); + startsAt.setSeconds(0); + startsAt.setMilliseconds(0); + const endsAt = new Date(this.endsAt); + endsAt.setSeconds(0); + endsAt.setMilliseconds(0); + return GenerateAlertmanagerSilenceData( + startsAt, + endsAt, + this.matchers, + this.author, + this.comment, + this.silenceID, + ); + } + + get toDuration() { + const data: DurationT = { + days: differenceInDays(this.endsAt, this.startsAt), + hours: differenceInHours(this.endsAt, this.startsAt) % 24, + minutes: differenceInMinutes(this.endsAt, this.startsAt) % 60, + }; + return data; + } } class SilenceFormStore { - toggle: SilenceFormStoreToggleT; - tab: SilenceFormStoreTabT; - data: SilenceFormStoreDataT; + toggle = new SilenceFormStoreToggle(); + tab = new SilenceFormStoreTab(); - constructor() { - this.toggle = observable( - { - visible: false as boolean, - blurred: false as boolean, - toggle() { - this.visible = !this.visible; - }, - hide() { - this.visible = false; - }, - show() { - this.visible = true; - }, - setBlur(val: boolean) { - this.blurred = val; - }, - }, - { - toggle: action.bound, - hide: action.bound, - show: action.bound, - setBlur: action.bound, - }, - ); - - this.tab = observable( - { - current: "editor" as SilenceFormTabT, - setTab(value: SilenceFormTabT) { - this.current = value; - }, - }, - { - setTab: action.bound, - }, - ); - - // form data is stored here, it's global (rather than attached to the form) - // so it can be manipulated from other parts of the code - // example: when user clicks a silence button on alert we should populate - // this form from that alert so user can easily silence that alert - this.data = observable( - { - currentStage: "form" as SilenceFormStageT, - wasValidated: false as boolean, - silenceID: null as null | undefined | string, - alertmanagers: [] as MultiValueOptionT[], - matchers: [] as MatcherWithIDT[], - startsAt: new Date(), - endsAt: addHours(new Date(), 1), - comment: "", - author: "", - requestsByCluster: {} as { [key: string]: ClusterRequestT }, - autofillMatchers: true as boolean, - resetInputs: true as boolean, - - get toBase64() { - const json = JSON.stringify({ - am: this.alertmanagers, - m: this.matchers.map((m: MatcherWithIDT) => ({ - n: m.name, - r: m.isRegex, - e: m.isEqual, - v: m.values.map((v) => v.value), - })), - d: differenceInMinutes(this.endsAt, this.startsAt), - c: this.comment, - }); - return window.btoa(json); - }, - - fromBase64(s: string): boolean { - let parsed: SilenceFormDataFromBase64; - try { - parsed = JSON.parse(window.atob(s)); - } catch (error) { - console.error(`Failed to parse JSON: ${error}`); - return false; - } - - const matchers: MatcherWithIDT[] = []; - parsed.m.forEach((m: SimplifiedMatcherT) => { - const matcher = NewEmptyMatcher(); - matcher.name = m.n; - matcher.isRegex = m.r; - matcher.isEqual = m.e; - matcher.values = m.v.map((v) => StringToOption(v)); - matchers.push(matcher); - }); - - if (matchers.length > 0) { - this.alertmanagers = parsed.am; - this.matchers = matchers; - - this.startsAt = new Date(); - this.endsAt = addMinutes(this.startsAt, parsed.d); - this.comment = parsed.c; - - this.silenceID = null; - this.autofillMatchers = false; - this.resetInputs = false; - return true; - } - - return false; - }, - - get isValid() { - if (this.alertmanagers.length === 0) return false; - if (this.matchers.length === 0) return false; - if ( - this.matchers.filter( - (m) => - m.name === "" || - m.values.length === 0 || - m.values.filter((v) => v.value === "").length > 0, - ).length > 0 - ) - return false; - if (this.comment === "") return false; - if (this.author === "") return false; - return true; - }, - - resetStartEnd() { - this.startsAt = new Date(); - this.endsAt = addHours(new Date(), 1); - }, - - resetProgress() { - this.currentStage = "form"; - this.wasValidated = false; - }, - - resetSilenceID() { - this.silenceID = null; - }, - - setSilenceID(id: string | null) { - this.silenceID = id; - }, - - setAlertmanagers(val: MultiValueOptionT[]) { - this.alertmanagers = val; - }, - - setAutofillMatchers(v: boolean) { - this.autofillMatchers = v; - }, - setResetInputs(v: boolean) { - this.resetInputs = v; - }, - - setStage(val: SilenceFormStageT) { - this.currentStage = val; - }, - - setMatchers(m: MatcherWithIDT[]) { - this.matchers = m; - }, - - // append a new empty matcher to the list - addEmptyMatcher() { - this.matchers.push(NewEmptyMatcher()); - }, - addMatcherWithID(m: MatcherWithIDT) { - this.matchers.push(m); - }, - - deleteMatcher(id: string) { - // only delete matchers if we have more than 1 - if (this.matchers.length > 1) { - this.matchers = this.matchers.filter((m) => m.id !== id); - } - }, - - // if alerts argument is not passed all group alerts will be used - fillMatchersFromGroup( - group: APIAlertGroupT, - stripLabels: string[], - alertmanagers: MultiValueOptionT[], - alerts?: APIAlertT[], - ) { - this.alertmanagers = alertmanagers; - - this.matchers = alerts - ? MatchersFromAlerts(group, stripLabels, alerts) - : MatchersFromGroup(group, stripLabels); - // ensure that silenceID is nulled, since it's used to edit silences - // and this is used to silence groups - this.silenceID = null; - // disable matcher autofill - this.autofillMatchers = false; - // disable alertmanager input reset - this.resetInputs = false; - }, - - fillFormFromSilence( - alertmanager: APIAlertmanagerUpstreamT, - silence: AlertmanagerSilencePayloadT, - ) { - this.silenceID = silence.id; - - this.alertmanagers = AlertmanagerClustersToOption({ - [alertmanager.cluster]: alertmanager.clusterMembers, - }); - - const matchers: MatcherWithIDT[] = []; - for (const m of silence.matchers) { - const matcher = NewEmptyMatcher(); - matcher.name = m.name; - matcher.values = UnpackRegexMatcherValues(m.isRegex, m.value); - matcher.isRegex = m.isRegex; - matcher.isEqual = m.isEqual === false ? false : true; - matchers.push(matcher); - } - this.matchers = matchers; - - this.startsAt = parseISO(silence.startsAt); - this.endsAt = parseISO(silence.endsAt); - this.comment = silence.comment; - this.author = silence.createdBy; - - // disable matcher autofill - this.autofillMatchers = false; - }, - - setAuthor(a: string) { - this.author = a; - }, - - setComment(c: string) { - this.comment = c; - }, - - verifyStarEnd() { - const now = new Date(); - now.setSeconds(0); - if (this.startsAt < now) { - this.startsAt = now; - } - - if (this.endsAt <= this.startsAt) { - this.endsAt = addMinutes(this.startsAt, 1); - } - }, - setStart(startsAt: Date) { - this.startsAt = startsAt; - }, - setEnd(endsAt: Date) { - this.endsAt = endsAt; - }, - incStart(minutes: number) { - this.startsAt = addMinutes(this.startsAt, minutes); - this.verifyStarEnd(); - }, - decStart(minutes: number) { - this.startsAt = subMinutes(this.startsAt, minutes); - this.verifyStarEnd(); - }, - - incEnd(minutes: number) { - this.endsAt = addMinutes(this.endsAt, minutes); - this.verifyStarEnd(); - }, - decEnd(minutes: number) { - this.endsAt = subMinutes(this.endsAt, minutes); - this.verifyStarEnd(); - }, - - setWasValidated(v: boolean) { - this.wasValidated = v; - }, - - setRequestsByCluster(val: { [key: string]: ClusterRequestT }) { - this.requestsByCluster = val; - }, - setRequestsByClusterUpdate(key: string, v: Partial) { - this.requestsByCluster[key] = { - ...this.requestsByCluster[key], - ...v, - }; - }, - - get toAlertmanagerPayload() { - const startsAt = new Date(this.startsAt); - startsAt.setSeconds(0); - startsAt.setMilliseconds(0); - const endsAt = new Date(this.endsAt); - endsAt.setSeconds(0); - endsAt.setMilliseconds(0); - return GenerateAlertmanagerSilenceData( - startsAt, - endsAt, - this.matchers, - this.author, - this.comment, - this.silenceID, - ); - }, - - get toDuration() { - const data: DurationT = { - days: differenceInDays(this.endsAt, this.startsAt), - hours: differenceInHours(this.endsAt, this.startsAt) % 24, - minutes: differenceInMinutes(this.endsAt, this.startsAt) % 60, - }; - return data; - }, - }, - { - toBase64: computed, - fromBase64: action.bound, - resetStartEnd: action.bound, - resetProgress: action.bound, - resetSilenceID: action.bound, - setSilenceID: action.bound, - setAlertmanagers: action.bound, - setAutofillMatchers: action.bound, - setResetInputs: action.bound, - setStage: action.bound, - setMatchers: action.bound, - addEmptyMatcher: action.bound, - addMatcherWithID: action.bound, - deleteMatcher: action.bound, - fillMatchersFromGroup: action.bound, - fillFormFromSilence: action.bound, - setAuthor: action.bound, - setComment: action.bound, - verifyStarEnd: action.bound, - setStart: action.bound, - setEnd: action.bound, - incStart: action.bound, - decStart: action.bound, - incEnd: action.bound, - decEnd: action.bound, - isValid: computed, - setWasValidated: action.bound, - setRequestsByCluster: action.bound, - setRequestsByClusterUpdate: action.bound, - toAlertmanagerPayload: computed, - toDuration: computed, - }, - { name: "Silence form store" }, - ); - } + // form data is stored here, it's global (rather than attached to the form) + // so it can be manipulated from other parts of the code + // example: when user clicks a silence button on alert we should populate + // this form from that alert so user can easily silence that alert + data = new SilenceFormStoreData(); } export { diff --git a/ui/src/e2e/stories.tsx b/ui/src/e2e/stories.tsx index 9c3886793..cc866e503 100644 --- a/ui/src/e2e/stories.tsx +++ b/ui/src/e2e/stories.tsx @@ -267,13 +267,19 @@ const makeGridAlertStore = (): AlertStore => { unprocessedGroup.totalAlerts = 3; const grids = alertStore.data.grids; - grids[0].alertGroups = [ + const alertGroups = [ suppressedGroup, unprocessedGroup, ...grids[0].alertGroups, ]; - grids[0].totalGroups = grids[0].alertGroups.length; - alertStore.data.setGrids(grids); + alertStore.data.setGrids([ + { + ...grids[0], + alertGroups: alertGroups, + totalGroups: alertGroups.length, + }, + ...grids.slice(1), + ]); return alertStore; };