feat(FN-4332): complete Step 4 — add notification failure controls

Fusion-Task-Id: FN-4332
Fusion-Task-Lineage: c920ac71-906b-458d-b496-1b60540b58f3
This commit is contained in:
Fusion
2026-05-13 13:01:49 -07:00
committed by gsxdsm
parent 0ee31db9e1
commit 09fc59944d
2 changed files with 71 additions and 0 deletions

View File

@@ -404,6 +404,8 @@ export function SettingsModal({
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyAccessToken: undefined,
failureNotificationMode: "sticky-only",
failureNotificationDelayMs: 30000,
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: "generic",
@@ -5022,6 +5024,47 @@ export function SettingsModal({
{renderScopeBanner()}
<h4 className="settings-section-heading">Notifications</h4>
<div className="notification-provider-card">
<div className="form-group">
<label htmlFor="failureNotificationMode">Failure notification mode</label>
<select
id="failureNotificationMode"
value={form.failureNotificationMode ?? "sticky-only"}
onChange={(e) => {
const value = e.target.value as "sticky-only" | "all";
setForm((f) => ({ ...f, failureNotificationMode: value }));
}}
>
<option value="sticky-only">Sticky failures only (default)</option>
<option value="all">All failures (legacy)</option>
</select>
<small>
Sticky-only suppresses notifications for transient failures that the engine auto-recovers. Choose &quot;All failures&quot; for the legacy immediate-notification behavior.
</small>
</div>
<div className="form-group">
<label htmlFor="failureNotificationDelayMs">Failure notification delay (ms)</label>
<input
id="failureNotificationDelayMs"
type="number"
min={0}
step={1000}
disabled={(form.failureNotificationMode ?? "sticky-only") === "all"}
value={form.failureNotificationDelayMs ?? 30000}
onChange={(e) => {
const parsed = Number(e.target.value);
setForm((f) => ({
...f,
failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0,
}));
}}
/>
<small>
How long a failure must persist before a push notification is sent. 0 = notify immediately.
</small>
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>ntfy</strong>

View File

@@ -3245,6 +3245,34 @@ describe("SettingsModal", () => {
expect(screen.getByText("Webhook")).toBeInTheDocument();
});
it("renders failure mode controls and persists updated values", async () => {
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement;
const delayInput = screen.getByLabelText("Failure notification delay (ms)") as HTMLInputElement;
expect(modeSelect.value).toBe("sticky-only");
expect(delayInput.value).toBe("30000");
await userEvent.selectOptions(modeSelect, "all");
expect(delayInput).toBeDisabled();
await userEvent.selectOptions(modeSelect, "sticky-only");
await userEvent.clear(delayInput);
await userEvent.type(delayInput, "45000");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
failureNotificationMode: "sticky-only",
failureNotificationDelayMs: 45000,
}),
);
});
});
it("shows ntfy fields when ntfy provider is enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
renderModal();