fix(ui): enable reactionRequiresObservable

This commit is contained in:
Lukasz Mierzwa
2026-03-09 14:03:27 +00:00
committed by Łukasz Mierzwa
parent c67d2f6e1d
commit 9aa2f93e00
13 changed files with 272 additions and 254 deletions

View File

@@ -1,4 +1,4 @@
import { Ref, CSSProperties, useRef, useState, useCallback } from "react";
import { FC, Ref, CSSProperties, useRef, useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
@@ -180,59 +180,63 @@ interface AlertMenuProps {
setIsMenuOpen: (isOpen: boolean) => void;
}
const AlertMenu = observer<AlertMenuProps>(
({ group, alert, alertStore, silenceFormStore, setIsMenuOpen }) => {
const [isHidden, setIsHidden] = useState<boolean>(true);
const AlertMenu: FC<AlertMenuProps> = ({
group,
alert,
alertStore,
silenceFormStore,
setIsMenuOpen,
}) => {
const [isHidden, setIsHidden] = useState<boolean>(true);
const toggle = useCallback(() => {
setIsMenuOpen(isHidden);
setIsHidden(!isHidden);
}, [isHidden, setIsMenuOpen]);
const toggle = useCallback(() => {
setIsMenuOpen(isHidden);
setIsHidden(!isHidden);
}, [isHidden, setIsMenuOpen]);
const hide = useCallback(() => {
setIsHidden(true);
setIsMenuOpen(false);
}, [setIsMenuOpen]);
const hide = useCallback(() => {
setIsHidden(true);
setIsMenuOpen(false);
}, [setIsMenuOpen]);
const rootRef = useRef<HTMLSpanElement | null>(null);
useOnClickOutside(rootRef, hide, !isHidden);
const rootRef = useRef<HTMLSpanElement | null>(null);
useOnClickOutside(rootRef, hide, !isHidden);
const { x, y, refs, strategy } = useFloating({
placement: "bottom-start",
middleware: [shift(), offset(5)],
});
const { x, y, refs, strategy } = useFloating({
placement: "bottom-start",
middleware: [shift(), offset(5)],
});
return (
<span ref={rootRef}>
<span
className="components-label components-label-with-hover px-1 me-1 badge bg-secondary cursor-pointer"
ref={refs.setReference}
onClick={toggle}
data-toggle="dropdown"
>
<FontAwesomeIcon
className="pe-1"
style={{ width: "0.8rem" }}
icon={faCaretDown}
/>
<DateFromNow timestamp={alert.startsAt} />
</span>
<DropdownSlide in={!isHidden} unmountOnExit>
<MenuContent
group={group}
alert={alert}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={hide}
x={x}
y={y}
floating={refs.setFloating}
strategy={strategy}
/>
</DropdownSlide>
return (
<span ref={rootRef}>
<span
className="components-label components-label-with-hover px-1 me-1 badge bg-secondary cursor-pointer"
ref={refs.setReference}
onClick={toggle}
data-toggle="dropdown"
>
<FontAwesomeIcon
className="pe-1"
style={{ width: "0.8rem" }}
icon={faCaretDown}
/>
<DateFromNow timestamp={alert.startsAt} />
</span>
);
},
);
<DropdownSlide in={!isHidden} unmountOnExit>
<MenuContent
group={group}
alert={alert}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={hide}
x={x}
y={y}
floating={refs.setFloating}
strategy={strategy}
/>
</DropdownSlide>
</span>
);
};
export { AlertMenu, MenuContent };

View File

@@ -1,7 +1,5 @@
import type { FC, MouseEvent } from "react";
import { observer } from "mobx-react-lite";
import type { APIAlertGroupT } from "Models/APITypes";
import type { AlertStore } from "Stores/AlertStore";
import type { SilenceFormStore } from "Stores/SilenceFormStore";
@@ -116,4 +114,4 @@ const GroupHeader: FC<{
);
};
export default observer(GroupHeader);
export default GroupHeader;

View File

@@ -8,8 +8,6 @@ import {
useCallback,
} from "react";
import { observer } from "mobx-react-lite";
import { useFloating, shift, offset } from "@floating-ui/react-dom";
import type { OnChangeValue } from "react-select";
@@ -134,7 +132,7 @@ const GridLabelSelect: FC<{
alertStore: AlertStore;
settingsStore: Settings;
grid: APIGridT;
}> = observer(({ alertStore, settingsStore, grid }) => {
}> = ({ alertStore, settingsStore, grid }) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const hide = useCallback(() => setIsVisible(false), []);
const toggle = useCallback(() => {
@@ -172,6 +170,6 @@ const GridLabelSelect: FC<{
</DropdownSlide>
</div>
);
});
};
export { GridLabelSelect };

View File

@@ -57,8 +57,6 @@ const FilteringCounterBadge: FC<{
[alertStore.filters, name, value, isAppend],
);
if (!alwaysVisible && counter === 0) return null;
const cs = GetClassAndStyle(
alertStore,
name,
@@ -66,6 +64,8 @@ const FilteringCounterBadge: FC<{
"rounded-pill components-label-with-hover",
);
if (!alwaysVisible && counter === 0) return null;
return (
<TooltipWrapper
title={`Click to only show ${name}=${value} alerts or Alt+Click to hide them`}

View File

@@ -17,7 +17,7 @@ import { StaticLabels } from "Common/Query";
const SilenceProgress: FC<{
silence: APISilenceT;
}> = observer(({ silence }) => {
}> = ({ silence }) => {
const diff = differenceInSeconds(parseISO(silence.endsAt), new Date());
if (diff <= 0) {
return (
@@ -35,7 +35,7 @@ const SilenceProgress: FC<{
Expires <DateFromNow timestamp={silence.endsAt} />
</span>
);
});
};
const SilenceComment: FC<{
cluster: string;
@@ -68,6 +68,9 @@ const SilenceComment: FC<{
),
);
const hasMultipleClusters =
Object.keys(alertStore.data.upstreams.clusters).length > 1;
return (
<>
<div className="d-flex flex-row">
@@ -93,8 +96,7 @@ const SilenceComment: FC<{
{collapsed ? (
<div className="d-flex flex-row justify-content-end flex-grow-1">
<SilenceProgress silence={silence} />
{Object.keys(alertStore.data.upstreams.clusters).length >
1 ? (
{hasMultipleClusters ? (
<span className="badge bg-secondary mx-1 components-label">
{cluster}
</span>

View File

@@ -1,7 +1,6 @@
import { FC, useEffect, useState } from "react";
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import { parseISO } from "date-fns/parseISO";
import { getUnixTime } from "date-fns/getUnixTime";
@@ -38,89 +37,86 @@ const ManagedSilence: FC<{
isOpen?: boolean;
onDidUpdate?: () => void;
isNested?: boolean;
}> = observer(
({
cluster,
alertCount,
alertCountAlwaysVisible,
silence,
alertStore,
silenceFormStore,
isOpen = false,
onDidUpdate,
isNested = false,
}) => {
useEffect(() => {
if (onDidUpdate) onDidUpdate();
});
}> = ({
cluster,
alertCount,
alertCountAlwaysVisible,
silence,
alertStore,
silenceFormStore,
isOpen = false,
onDidUpdate,
isNested = false,
}) => {
useEffect(() => {
if (onDidUpdate) onDidUpdate();
});
const [showDetails, setShowDetails] = useState<boolean>(isOpen);
const [showDetails, setShowDetails] = useState<boolean>(isOpen);
const onEditSilence = action(() => {
const alertmanager = GetAlertmanager(alertStore, cluster);
const onEditSilence = action(() => {
const alertmanager = GetAlertmanager(alertStore, cluster);
silenceFormStore.data.fillFormFromSilence(alertmanager, silence);
silenceFormStore.data.resetProgress();
silenceFormStore.tab.setTab("editor");
silenceFormStore.toggle.show();
});
silenceFormStore.data.fillFormFromSilence(alertmanager, silence);
silenceFormStore.data.resetProgress();
silenceFormStore.tab.setTab("editor");
silenceFormStore.toggle.show();
});
const [progress, setProgress] = useState<number>(() =>
calculatePercent(silence.startsAt, silence.endsAt),
);
const [progress, setProgress] = useState<number>(() =>
calculatePercent(silence.startsAt, silence.endsAt),
);
useEffect(() => {
const timer = setInterval(() => {
setProgress(calculatePercent(silence.startsAt, silence.endsAt));
}, 30 * 1000);
return () => clearInterval(timer);
}, [silence.startsAt, silence.endsAt]);
useEffect(() => {
const timer = setInterval(() => {
setProgress(calculatePercent(silence.startsAt, silence.endsAt));
}, 30 * 1000);
return () => clearInterval(timer);
}, [silence.startsAt, silence.endsAt]);
return (
<div className="card my-1 components-managed-silence w-100">
<div className="card-header rounded-0 border-bottom-0 px-2">
<SilenceComment
alertStore={alertStore}
return (
<div className="card my-1 components-managed-silence w-100">
<div className="card-header rounded-0 border-bottom-0 px-2">
<SilenceComment
alertStore={alertStore}
cluster={cluster}
silence={silence}
alertCount={alertCount}
alertCountAlwaysVisible={alertCountAlwaysVisible}
collapsed={!showDetails}
collapseToggle={() => setShowDetails(!showDetails)}
/>
</div>
{showDetails ? (
<div className="card-body pt-0 px-2">
<SilenceDetails
cluster={cluster}
silence={silence}
alertCount={alertCount}
alertCountAlwaysVisible={alertCountAlwaysVisible}
collapsed={!showDetails}
collapseToggle={() => setShowDetails(!showDetails)}
/>
</div>
{showDetails ? (
<div className="card-body pt-0 px-2">
<SilenceDetails
cluster={cluster}
silence={silence}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
onEditSilence={onEditSilence}
isUpper={isNested}
/>
</div>
) : null}
<div className="progress silence-progress mx-2 mb-1">
<div
className={
progress > 90
? "progress-bar bg-danger"
: progress > 75
? "progress-bar bg-warning"
: "progress-bar bg-success"
}
role="progressbar"
style={{ width: progress + "%" }}
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
onEditSilence={onEditSilence}
isUpper={isNested}
/>
</div>
) : null}
<div className="progress silence-progress mx-2 mb-1">
<div
className={
progress > 90
? "progress-bar bg-danger"
: progress > 75
? "progress-bar bg-warning"
: "progress-bar bg-success"
}
role="progressbar"
style={{ width: progress + "%" }}
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
},
);
ManagedSilence.displayName = "ManagedSilence";
</div>
);
};
export { ManagedSilence, GetAlertmanager };

View File

@@ -64,7 +64,7 @@ const LabelsTable: FC<{
counters: CountersResponseT;
showAllLabels: boolean;
toggleAllLabels: () => void;
}> = observer(({ alertStore, counters, showAllLabels, toggleAllLabels }) => (
}> = ({ alertStore, counters, showAllLabels, toggleAllLabels }) => (
<>
<table
className="table table-borderless top-labels"
@@ -104,7 +104,7 @@ const LabelsTable: FC<{
</tbody>
</table>
</>
));
);
const NothingToShow: FC = () => (
<div className="px-2 py-5 bg-transparent">

View File

@@ -1,7 +1,5 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faAngleUp } from "@fortawesome/free-solid-svg-icons/faAngleUp";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons/faAngleDown";
@@ -11,7 +9,7 @@ const Duration: FC<{
label: string;
onInc: () => void;
onDec: () => void;
}> = observer(({ value, label, onInc, onDec }) => {
}> = ({ value, label, onInc, onDec }) => {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -78,6 +76,6 @@ const Duration: FC<{
</table>
</div>
);
});
};
export { Duration };

View File

@@ -1,7 +1,5 @@
import { FC, useEffect, useRef, MouseEvent, WheelEvent } from "react";
import { observer } from "mobx-react-lite";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faAngleUp } from "@fortawesome/free-solid-svg-icons/faAngleUp";
@@ -30,94 +28,92 @@ const HourMinute: FC<{
onHourDec: () => void;
onMinuteInc: () => void;
onMinuteDec: () => void;
}> = observer(
({ dateValue, onHourInc, onHourDec, onMinuteInc, onMinuteDec }) => {
const rootRef = useRef<HTMLDivElement | null>(null);
}> = ({ dateValue, onHourInc, onHourDec, onMinuteInc, onMinuteDec }) => {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const cancelWheel = (event: Event) => event.preventDefault();
useEffect(() => {
const cancelWheel = (event: Event) => event.preventDefault();
const elem = rootRef.current as HTMLDivElement;
const elem = rootRef.current as HTMLDivElement;
elem.addEventListener("wheel", cancelWheel, { passive: false });
elem.addEventListener("wheel", cancelWheel, { passive: false });
return () => {
elem.removeEventListener("wheel", cancelWheel);
};
}, []);
const onHourWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
onHourInc();
} else {
onHourDec();
}
return () => {
elem.removeEventListener("wheel", cancelWheel);
};
}, []);
const onMinuteWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
onMinuteInc();
} else {
onMinuteDec();
}
};
const onHourWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
onHourInc();
} else {
onHourDec();
}
};
const hour = dateValue.getHours();
const minute = dateValue.getMinutes();
const onMinuteWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
onMinuteInc();
} else {
onMinuteDec();
}
};
return (
<div
ref={rootRef}
className="d-flex justify-content-center align-items-center components-hour-minute"
>
<table className="text-center border-0">
<tbody>
<tr>
<IconTd
icon={faAngleUp}
onClick={onHourInc}
onWheel={onHourWheel}
className="components-hour-up with-click"
/>
<td />
<IconTd
icon={faAngleUp}
onClick={onMinuteInc}
onWheel={onMinuteWheel}
className="components-minute-up with-click"
/>
</tr>
<tr>
<td className="components-hour" onWheel={onHourWheel}>
<h2>{hour > 9 ? hour : `0${hour}`}</h2>
</td>
<td>
<h2 className="mx-2">:</h2>
</td>
<td className="components-minute" onWheel={onMinuteWheel}>
<h2>{minute > 9 ? minute : `0${minute}`}</h2>
</td>
</tr>
<tr>
<IconTd
icon={faAngleDown}
onClick={onHourDec}
onWheel={onHourWheel}
className="components-hour-down with-click"
/>
<td />
<IconTd
icon={faAngleDown}
onClick={onMinuteDec}
onWheel={onMinuteWheel}
className="components-minute-down with-click"
/>
</tr>
</tbody>
</table>
</div>
);
},
);
const hour = dateValue.getHours();
const minute = dateValue.getMinutes();
return (
<div
ref={rootRef}
className="d-flex justify-content-center align-items-center components-hour-minute"
>
<table className="text-center border-0">
<tbody>
<tr>
<IconTd
icon={faAngleUp}
onClick={onHourInc}
onWheel={onHourWheel}
className="components-hour-up with-click"
/>
<td />
<IconTd
icon={faAngleUp}
onClick={onMinuteInc}
onWheel={onMinuteWheel}
className="components-minute-up with-click"
/>
</tr>
<tr>
<td className="components-hour" onWheel={onHourWheel}>
<h2>{hour > 9 ? hour : `0${hour}`}</h2>
</td>
<td>
<h2 className="mx-2">:</h2>
</td>
<td className="components-minute" onWheel={onMinuteWheel}>
<h2>{minute > 9 ? minute : `0${minute}`}</h2>
</td>
</tr>
<tr>
<IconTd
icon={faAngleDown}
onClick={onHourDec}
onWheel={onHourWheel}
className="components-hour-down with-click"
/>
<td />
<IconTd
icon={faAngleDown}
onClick={onMinuteDec}
onWheel={onMinuteWheel}
className="components-minute-down with-click"
/>
</tr>
</tbody>
</table>
</div>
);
};
export { HourMinute };

View File

@@ -1,5 +1,7 @@
import { use, FC } from "react";
import { action } from "mobx";
import Creatable from "react-select/creatable";
import { FormatBackendURI } from "Stores/AlertStore";
@@ -11,6 +13,12 @@ import { AnimatedMenu } from "Components/Select";
import { NewLabelName, OptionT, StringToOption } from "Common/Select";
import { OnChangeValue } from "react-select";
const setMatcherName = action(
(matcher: MatcherWithIDT, option: OnChangeValue<OptionT, false>) => {
matcher.name = (option as OptionT).value;
},
);
const LabelNameInput: FC<{
matcher: MatcherWithIDT;
isValid: boolean;
@@ -32,10 +40,9 @@ const LabelNameInput: FC<{
response ? response.map((value: string) => StringToOption(value)) : []
}
placeholder={isValid ? "Label name" : <ValidationError />}
onChange={(option: OnChangeValue<OptionT, false>) => {
// eslint-disable-next-line react-compiler/react-compiler -- intentional MobX observable mutation
matcher.name = (option as OptionT).value;
}}
onChange={(option: OnChangeValue<OptionT, false>) =>
setMatcherName(matcher, option)
}
hideSelectedOptions
components={{ Menu: AnimatedMenu }}
/>

View File

@@ -1,6 +1,6 @@
import { use, FC, useEffect } from "react";
import { observer } from "mobx-react-lite";
import { action } from "mobx";
import {
ActionMeta,
@@ -21,6 +21,23 @@ import { ThemeContext } from "Components/Theme";
import { AnimatedMenuMultiple } from "Components/Select";
import { MatchCounter } from "./MatchCounter";
const updateMatcherValues = action(
(
matcher: MatcherWithIDT,
newValue: OnChangeValue<OptionT, true>,
meta: ActionMeta<OptionT>,
) => {
matcher.values = newValue as OptionT[];
// force regex if we have multiple values
if (matcher.values.length > 1 && matcher.isRegex === false) {
matcher.isRegex = true;
}
if (meta.action === "create-option") {
matcher.values[matcher.values.length - 1].wasCreated = true;
}
},
);
const GenerateHashFromMatchers = (
silenceFormStore: SilenceFormStore,
matcher: MatcherWithIDT,
@@ -66,7 +83,7 @@ const LabelValueInput: FC<{
silenceFormStore: SilenceFormStore;
matcher: MatcherWithIDT;
isValid: boolean;
}> = observer(({ silenceFormStore, matcher, isValid }) => {
}> = ({ silenceFormStore, matcher, isValid }) => {
const { response, get, cancelGet } = useFetchGet<string[]>(
FormatBackendURI(`labelValues.json?name=${matcher.name}`),
{ autorun: false },
@@ -97,16 +114,7 @@ const LabelValueInput: FC<{
onChange={(
newValue: OnChangeValue<OptionT, true>,
meta: ActionMeta<OptionT>,
) => {
matcher.values = newValue as OptionT[];
// force regex if we have multiple values
if (matcher.values.length > 1 && matcher.isRegex === false) {
matcher.isRegex = true;
}
if (meta.action === "create-option") {
matcher.values[matcher.values.length - 1].wasCreated = true;
}
}}
) => updateMatcherValues(matcher, newValue, meta)}
hideSelectedOptions
isMulti
components={{
@@ -116,6 +124,6 @@ const LabelValueInput: FC<{
}}
/>
);
});
};
export { LabelValueInput };

View File

@@ -1,6 +1,6 @@
import type { FC } from "react";
import { observer } from "mobx-react-lite";
import { action } from "mobx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
@@ -10,6 +10,20 @@ import { TooltipWrapper } from "Components/TooltipWrapper";
import { LabelNameInput } from "./LabelNameInput";
import { LabelValueInput } from "./LabelValueInput";
const setMatcherIsEqual = action(
(matcher: MatcherWithIDT, checked: boolean) => {
matcher.isEqual = checked;
},
);
const setMatcherIsRegex = action(
(matcher: MatcherWithIDT, checked: boolean) => {
if (matcher.values.length <= 1) {
matcher.isRegex = checked;
}
},
);
const SilenceMatch: FC<{
silenceFormStore: SilenceFormStore;
matcher: MatcherWithIDT;
@@ -48,10 +62,9 @@ const SilenceMatch: FC<{
type="checkbox"
value=""
checked={matcher.isEqual}
onChange={(event) => {
// eslint-disable-next-line react-compiler/react-compiler -- intentional MobX observable mutation
matcher.isEqual = event.target.checked;
}}
onChange={(event) =>
setMatcherIsEqual(matcher, event.target.checked)
}
/>
<label
className="form-check-label cursor-pointer me-3"
@@ -67,11 +80,9 @@ const SilenceMatch: FC<{
type="checkbox"
value=""
checked={matcher.isRegex}
onChange={(event) => {
if (matcher.values.length <= 1) {
matcher.isRegex = event.target.checked;
}
}}
onChange={(event) =>
setMatcherIsRegex(matcher, event.target.checked)
}
disabled={matcher.values.length > 1}
/>
<label
@@ -99,4 +110,4 @@ const SilenceMatch: FC<{
);
};
export default observer(SilenceMatch);
export default SilenceMatch;

View File

@@ -24,7 +24,7 @@ fetchMock.mockGlobal();
configure({
enforceActions: "observed",
computedRequiresReaction: true,
// reactionRequiresObservable: true,
reactionRequiresObservable: true,
// observableRequiresReaction: true,
});