From 6ee9366cd9cba97c961071a50245db57a10c8068 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 12:41:47 +0300 Subject: [PATCH 1/4] Recompute pagination pages when the result count changes total was read inside the useMemo but left out of its dependencies, so any list that filters without touching offset or limit kept one page and lost the rest. --- src/components/common/DataListViewSettingsEditor.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/common/DataListViewSettingsEditor.tsx b/src/components/common/DataListViewSettingsEditor.tsx index 17518a7..c0372fc 100644 --- a/src/components/common/DataListViewSettingsEditor.tsx +++ b/src/components/common/DataListViewSettingsEditor.tsx @@ -38,7 +38,10 @@ export const DataListViewSettingsEditor: React.FC = ({ const _pages = [] for (let i = 0; i < _totalPages; ++i) _pages.push(i); return {pages: _pages, pageIndex: _pageIndex, totalPages: _totalPages} - }, [offset, limit]); + // `total` belongs here: it is read above, and without it the page buttons keep whatever count + // they were first built with. Any caller whose result count changes while offset and limit + // stay put — a filter, a search — otherwise loses the pages past the first. + }, [offset, limit, total]); const handlePageChange = (newOffset: number) => { if (newOffset < 0 || newOffset >= total) From 3e7d9b7f6af033081bc1456b38685556ca44fbb6 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 12:42:02 +0300 Subject: [PATCH 2/4] Show budget usage and alert routing as one subscriptions table Both were keyed by the same subscription and group, so reading them together meant matching two tables by eye. Adds the budget-exhausted alert editor at policy, group and subscription level. --- src/client/apis/retryPoliciesApi.ts | 16 +- .../RetryPolicies/AddEditRetryGroupModal.tsx | 31 +- .../RetryPolicies/RetryAlertEditor.tsx | 113 ++++++ .../RetryPolicies/RetryBudgetUsage.tsx | 113 ------ .../RetryPolicies/RetryGroupsEditor.tsx | 31 +- .../RetryPolicySubscriptions.tsx | 328 ++++++++++++++++++ src/components/RetryPolicy.tsx | 74 +++- src/types/retryPolicies.ts | 80 ++++- 8 files changed, 646 insertions(+), 140 deletions(-) create mode 100644 src/components/RetryPolicies/RetryAlertEditor.tsx delete mode 100644 src/components/RetryPolicies/RetryBudgetUsage.tsx create mode 100644 src/components/RetryPolicies/RetryPolicySubscriptions.tsx diff --git a/src/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index deea538..dfb0326 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -7,7 +7,8 @@ import { RetryGroupUsageRow, RetryPolicyResetUsage, TestRetryPolicyRequest, - TestRetryPolicyResponse + TestRetryPolicyResponse, + RetryAlertOverrideSave } from "src/types/retryPolicies"; import {ApiPagedResponse} from "src/types/common"; @@ -42,7 +43,8 @@ export const RetryPoliciesApi = createApi({ }) }), updateRetryPolicy: builder.mutation<{}, { id: number } & RetryPolicyModel>({ - // Saving drops the counters of any removed group, so the usage panel must refetch. + // Saving drops the counters of any removed group and can change a group's own alert, + // which is where inheriting rows resolve to, so the subscriptions panel must refetch. invalidatesTags: ['retryPolicies', 'retryPolicyUsage'], query: body => ({ url: `RetryPolicies/${body.id}`, @@ -87,6 +89,15 @@ export const RetryPoliciesApi = createApi({ method: "POST", body }) + }), + saveRetryAlertOverride: builder.mutation<{}, { id: number } & RetryAlertOverrideSave>({ + // Saving an override changes where other rows resolve to as well, so refetch the lot. + invalidatesTags: ['retryPolicyUsage'], + query: ({id, ...body}) => ({ + url: `RetryPolicies/${id}/savealertoverride`, + method: "POST", + body + }) }) }) }); @@ -102,4 +113,5 @@ export const { useTestRetryPolicyMutation, useRetryPolicyUsageQuery, useResetRetryPolicyUsageMutation, + useSaveRetryAlertOverrideMutation, } = RetryPoliciesApi; diff --git a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx index d50a2a1..c479875 100644 --- a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx +++ b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx @@ -7,9 +7,11 @@ import {ChoiceEditor} from "src/components/common/forms/ChoiceEditor"; import FieldTooltip from "src/components/common/forms/FieldTooltip"; import MatchersEditor from "src/components/RetryPolicies/MatchersEditor"; import {OptionType} from "src/types/common"; +import RetryAlertEditor from "src/components/RetryPolicies/RetryAlertEditor"; import { DelayStrategy, RetryAction, + RetryAlertMode, RetryGroup, XchangeResultType, delayStrategyTypeOptions, @@ -21,6 +23,8 @@ type Props = { onClose: () => void onAdd: (group: RetryGroup) => void initial?: RetryGroup + policyAlertHandlerId?: string | null + policyAlertHandlerProperties?: Record | null } const emptyGroup = (): RetryGroup => ({ @@ -36,7 +40,8 @@ const emptyGroup = (): RetryGroup => ({ maxAttemptsTotal: 10, delayStrategy: {type: "fixed", delayMs: 5000} }, - notes: "" + notes: "", + alertMode: RetryAlertMode.Inherit }) // The UI only ever collects/displays delay strategy times in seconds — storage @@ -58,7 +63,14 @@ const defaultDelayStrategyFor = (type: string): DelayStrategy => { } } -const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initial}) => { +const AddEditRetryGroupModal: React.FC = ({ + visible, + onClose, + onAdd, + initial, + policyAlertHandlerId, + policyAlertHandlerProperties + }) => { const [group, setGroup] = useState(initial ?? emptyGroup()); @@ -169,7 +181,7 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi onChangeBudgetField("maxAttemptsPerError", Number(v))}/> - + onChangeBudgetField("maxAttemptsTotal", Number(v))}/> @@ -225,6 +237,19 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi )} +
+ setGroup((g) => ({...g, ...v}))} + inheritedFrom={"the policy default"} + inheritedHandlerId={policyAlertHandlerId} + inheritedProperties={policyAlertHandlerProperties}/> +
+ onChangeField("notes", v)}/> diff --git a/src/components/RetryPolicies/RetryAlertEditor.tsx b/src/components/RetryPolicies/RetryAlertEditor.tsx new file mode 100644 index 0000000..db08647 --- /dev/null +++ b/src/components/RetryPolicies/RetryAlertEditor.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import AdapterEditor from "src/components/Subscriptions/AdapterEditor"; +import { + RetryAlertMode, + pairsFromRecord, + recordFromPairs +} from "src/types/retryPolicies"; + +export interface RetryAlertValue { + alertMode: RetryAlertMode + alertHandlerId?: string | null + alertHandlerProperties?: Record | null +} + +interface Props { + value: RetryAlertValue + onChange: (value: RetryAlertValue) => void + /** What "Inherit" resolves to at the level above, e.g. "the policy default". */ + inheritedFrom: string + /** The handler that level uses, so inheriting can be described rather than just named. */ + inheritedHandlerId?: string | null + inheritedProperties?: Record | null + title?: string +} + +const modes: { mode: RetryAlertMode, label: string }[] = [ + {mode: RetryAlertMode.Inherit, label: "Inherit"}, + {mode: RetryAlertMode.Send, label: "Send via…"}, + {mode: RetryAlertMode.Silent, label: "Silent"}, +]; + +// A level that overrides REPLACES the level above rather than merging into it, so choosing +// "Send via…" means re-entering the handler and every property it needs. That is what the +// "copy from" button is for — otherwise the api key gets retyped by hand and eventually typo'd. +const RetryAlertEditor: React.FC = ({ + value, + onChange, + inheritedFrom, + inheritedHandlerId, + inheritedProperties, + title = "Budget exhausted alert" + }) => { + + const mode = value.alertMode ?? RetryAlertMode.Inherit; + + const setMode = (next: RetryAlertMode) => { + // Leaving Send behind drops its handler, so a level that no longer sends cannot keep + // stale config that the UI would still show. + if (next === RetryAlertMode.Send) + onChange({...value, alertMode: next}); + else + onChange({alertMode: next, alertHandlerId: null, alertHandlerProperties: null}); + }; + + const copyFromInherited = () => onChange({ + alertMode: RetryAlertMode.Send, + alertHandlerId: inheritedHandlerId, + alertHandlerProperties: inheritedProperties ?? {} + }); + + return ( +
+

{title}

+ +
+ {modes.map(m => ( + + ))} +
+ + {mode === RetryAlertMode.Inherit && +

+ {inheritedHandlerId + ? <>Uses {inheritedFrom} — {inheritedHandlerId}. + : <>Follows {inheritedFrom}, which currently sends no alert.} +

} + + {mode === RetryAlertMode.Silent && +

+ Sends nothing, even when {inheritedFrom} defines an alert. +

} + + {mode === RetryAlertMode.Send && <> +
+

+ Replaces {inheritedFrom} entirely — set the handler and all of its properties here. +

+ {inheritedHandlerId && + } +
+ onChange({...value, alertHandlerId: v})} + props={pairsFromRecord(value.alertHandlerProperties)} + onPropsChange={(p) => onChange({ + ...value, + alertHandlerProperties: recordFromPairs(p) + })}/> + } +
+ ); +} + +export default RetryAlertEditor; diff --git a/src/components/RetryPolicies/RetryBudgetUsage.tsx b/src/components/RetryPolicies/RetryBudgetUsage.tsx deleted file mode 100644 index 04015c9..0000000 --- a/src/components/RetryPolicies/RetryBudgetUsage.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from "react"; -import {useResetRetryPolicyUsageMutation, useRetryPolicyUsageQuery} from "src/client/apis/retryPoliciesApi"; -import Button from "src/components/common/forms/Button"; -import FormField from "src/components/common/forms/FormField"; -import Authorize from "src/components/common/authorize/authorize"; -import dayjs from "dayjs"; - -interface Props { - policyId: number -} - -// "Max attempts total" never resets on its own, so an integration that reaches its ceiling stops -// being retried until someone clears the counter here. Without this panel that state is invisible. -const RetryBudgetUsage: React.FC = ({policyId}) => { - - const {data, isLoading, isError, refetch} = useRetryPolicyUsageQuery(policyId) - const [reset] = useResetRetryPolicyUsageMutation() - - const rows = data ?? [] - const exhaustedCount = rows.filter(r => r.exhausted).length - - return ( - -

- Counted separately for each integration. Integrations that have never failed under this - policy do not appear. -

- - {isLoading &&

Loading…

} - - {/* Never fall through to the empty state on failure: "no budget spent" would claim - every group is untouched when the truth is that we could not find out. */} - {isError && -
- Could not load budget usage. - -
} - - {!isLoading && !isError && rows.length === 0 && -

- No budget spent — every group has its full allowance. -

} - - {!isError && rows.length > 0 &&
- {exhaustedCount > 0 && -

- {exhaustedCount === 1 - ? "1 integration has exhausted its budget and is no longer being retried." - : `${exhaustedCount} integrations have exhausted their budget and are no longer being retried.`} -

} - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - ))} - -
IntegrationGroupUsedLast retry
- {r.subscriptionName} - - {r.groupName} - - - {r.attemptsUsed} / {r.maxAttemptsTotal} - - {r.exhausted && - - Exhausted - } - - {dayjs(r.lastAttemptOn).format("YYYY-MM-DD HH:mm")} - - - - -
- - -
- -
-
-
} -
- ); -} - -export default RetryBudgetUsage; diff --git a/src/components/RetryPolicies/RetryGroupsEditor.tsx b/src/components/RetryPolicies/RetryGroupsEditor.tsx index 329eb42..25566f2 100644 --- a/src/components/RetryPolicies/RetryGroupsEditor.tsx +++ b/src/components/RetryPolicies/RetryGroupsEditor.tsx @@ -1,5 +1,5 @@ import FormField from "src/components/common/forms/FormField"; -import {RetryGroup} from "src/types/retryPolicies"; +import {RetryAlertMode, RetryGroup} from "src/types/retryPolicies"; import React, {useState} from "react"; import Button from "src/components/common/forms/Button"; import AddEditRetryGroupModal from "src/components/RetryPolicies/AddEditRetryGroupModal"; @@ -11,9 +11,18 @@ interface Props { groups?: RetryGroup[] title: string onChange: (val: RetryGroup[]) => void + /** The policy default a group inherits, so the modal can describe and copy it. */ + policyAlertHandlerId?: string | null + policyAlertHandlerProperties?: Record | null } -const RetryGroupsEditor: React.FC = ({title, groups, onChange}) => { +const RetryGroupsEditor: React.FC = ({ + title, + groups, + onChange, + policyAlertHandlerId, + policyAlertHandlerProperties + }) => { const [visibleModal, setVisibleModal] = useState<"NONE" | "ADD_EDIT">("NONE") const [editingGroup, setEditingGroup] = useState(undefined) @@ -48,17 +57,19 @@ const RetryGroupsEditor: React.FC = ({title, groups, onChange}) => { visible={visibleModal === "ADD_EDIT"} initial={editingGroup} onAdd={onAdd} + policyAlertHandlerId={policyAlertHandlerId} + policyAlertHandlerProperties={policyAlertHandlerProperties} onClose={() => setVisibleModal("NONE")}/> } - } onClickAction={onClickAdd}>

- Groups are evaluated in priority order (lowest first). The first enabled group whose matchers - match the failure wins, and its action and retry budget are applied — remaining groups are - skipped. If no group matches, the failure is not retried. + Lowest priority first, and the first matching group wins. No match means no retry.

@@ -69,6 +80,7 @@ const RetryGroupsEditor: React.FC = ({title, groups, onChange}) => { + @@ -101,6 +113,13 @@ const RetryGroupsEditor: React.FC = ({title, groups, onChange}) => { + diff --git a/src/components/RetryPolicies/RetryPolicySubscriptions.tsx b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx new file mode 100644 index 0000000..58b7f26 --- /dev/null +++ b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx @@ -0,0 +1,328 @@ +import React, {useState} from "react"; +import dayjs from "dayjs"; +import { + useResetRetryPolicyUsageMutation, + useRetryPolicyUsageQuery, + useSaveRetryAlertOverrideMutation +} from "src/client/apis/retryPoliciesApi"; +import Button from "src/components/common/forms/Button"; +import FormField from "src/components/common/forms/FormField"; +import TextEditor from "src/components/common/forms/TextEditor"; +import Tab from "src/components/common/forms/Tab"; +import TabNavigator from "src/components/common/forms/TabNavigator"; +import {DataListViewSettingsEditor} from "src/components/common/DataListViewSettingsEditor"; +import Modal from "src/components/common/Modal"; +import Authorize from "src/components/common/authorize/authorize"; +import RetryAlertEditor, {RetryAlertValue} from "src/components/RetryPolicies/RetryAlertEditor"; +import { + RetryAlertLevel, + RetryAlertMode, + RetryGroupUsageRow, + retryAlertLevelLabels +} from "src/types/retryPolicies"; + +interface Props { + policyId: number +} + +// Spent budget and alert destination are keyed by the same subscription-and-group pair, so they +// belong on one row: the question worth asking about an exhausted budget is whether anyone was +// told, and answering it from two tables means matching them by eye. +// +// The server sorts worst-first — stopped retrying, then alerting nowhere, then most spent — so the +// rows that matter lead the table before any filter is touched. +type Filter = { + id: string + title: string + // What the filter means, not only how many rows it has: a count says how many, not why to care. + hint: string + keep: (row: RetryGroupUsageRow) => boolean +} + +const filters: Filter[] = [ + { + id: "attention", + title: "Needs attention", + hint: "Budgets that have run out, and pairs whose alert would go nowhere.", + keep: r => r.exhausted || !r.resolvedHandlerId + }, + { + id: "exhausted", + title: "Exhausted", + hint: "No longer being retried at all until the counter is reset.", + keep: r => r.exhausted + }, + { + id: "noAlert", + title: "No alert", + hint: "No level configures an alert, or one silences it — running out will be invisible.", + keep: r => !r.resolvedHandlerId + }, + { + id: "overridden", + title: "Overridden", + hint: "These pairs set their own alert instead of following the group or policy.", + keep: r => r.alertMode !== RetryAlertMode.Inherit + }, + { + id: "all", + title: "All", + hint: "Every subscription and group using this policy, whether or not it has ever failed.", + keep: () => true + } +]; + +// Why nothing sends, short enough for a column. Which level silenced it matters: one subscription +// opting out and a whole group being switched off are different decisions. +const silenceLabels: Record = { + [RetryAlertLevel.SubscriptionGroup]: "Silenced (subscription)", + [RetryAlertLevel.Group]: "Silenced (group)", + [RetryAlertLevel.Policy]: "Silenced (policy)", +} + +// Shared by the header and body cells so columns stay aligned as padding is tuned. +const cell = "px-3 py-1.5 whitespace-nowrap" + +const RetryPolicySubscriptions: React.FC = ({policyId}) => { + + const {data, isLoading, isError, refetch} = useRetryPolicyUsageQuery(policyId) + const [reset] = useResetRetryPolicyUsageMutation() + const [save] = useSaveRetryAlertOverrideMutation() + + const [filterId, setFilterId] = useState("attention") + const [search, setSearch] = useState("") + const [view, setView] = useState({offset: 0, limit: 10}) + const [editing, setEditing] = useState() + const [draft, setDraft] = useState() + + const rows = data ?? [] + const filter = filters.find(f => f.id === filterId) ?? filters[0] + + const term = search.trim().toLowerCase() + const searched = term + ? rows.filter(r => r.subscriptionName?.toLowerCase().includes(term) + || r.groupName?.toLowerCase().includes(term)) + : rows + const matching = searched.filter(filter.keep) + const visible = matching.slice(view.offset, view.offset + view.limit) + + // Narrowing the set has to send you back to the first page, or the offset can land past the end + // and the table looks empty when it is not. + const narrow = (change: () => void) => { + change() + setView(v => ({...v, offset: 0})) + } + + const onEdit = (row: RetryGroupUsageRow) => { + setEditing(row) + setDraft({ + alertMode: row.alertMode ?? RetryAlertMode.Inherit, + alertHandlerId: row.overrideHandlerId, + alertHandlerProperties: row.overrideHandlerProperties + }) + } + + const onSubmit = async () => { + if (!editing || !draft) return + await save({ + id: policyId, + subscriptionId: editing.subscriptionId, + groupId: editing.groupId, + ...draft + }) + setEditing(undefined) + setDraft(undefined) + } + + // What this pair would fall back to if its own override were removed — the group's setting when + // the group sends or silences, otherwise the policy default. The backend walks the same order; + // this only describes it. + const inheritedDescription = (row: RetryGroupUsageRow) => + row.resolvedFrom === RetryAlertLevel.SubscriptionGroup + ? "the group or policy setting" + : row.resolvedFrom + ? `the ${retryAlertLevelLabels[row.resolvedFrom].toLowerCase()} setting` + : "the group and policy settings"; + + return ( + + + {editing && draft && + setEditing(undefined)} submitLabel={"Save"} onSubmit={onSubmit}> +

+ {editing.subscriptionName} + {" — "} + {editing.groupName} +

+ +
} + + {isLoading &&

Loading…

} + + {/* Never fall through to the empty state on failure: "nothing to worry about" would be a + claim we cannot make when the truth is that we could not find out. */} + {isError && +
+ Could not load this policy's subscriptions. + +
} + + {!isLoading && !isError && rows.length === 0 && +

+ No subscriptions use this policy yet. +

} + + {!isError && rows.length > 0 &&
+ + {filters.map(f => { + const count = searched.filter(f.keep).length + return ( + narrow(() => setFilterId(f.id))}> + {f.title} + 0 && ["attention", "exhausted", "noAlert"].includes(f.id) + ? "text-red-600" : "text-gray-400"}`}> + {count} + + + ) + })} + + +
+

{filter.hint}

+ narrow(() => setSearch(t))}/> +
+ + {matching.length === 0 + ?

+ {term + ? <>Nothing matches “{search}” here. + : <>Nothing here — {filter.title.toLowerCase()} is empty.} +

+ /* A page at a time rather than a scrollbox: the row count stays predictable, so + the sections around this one do not move as the data grows. Only horizontal + overflow is handled here. */ + :
+
Applies to Matchers ActionAlert Enabled
{g.action} + {g.alertMode === RetryAlertMode.Send + ? {g.alertHandlerId} + : g.alertMode === RetryAlertMode.Silent + ? Silent + : Inherit} + {g.enabled === false ? "False" : "True"}
+ + + + + + + + + + + + + + {visible.map((r) => ( + + + + + + + + {/* Exhausted with a handler but no alert time is worth calling + out: either it ran out before alerts existed, or the alert + never got raised. */} + + + + ))} + +
SubscriptionGroupUsedLast retryAlertSet byAlerted
+ {r.subscriptionName} + {r.groupName} + + {r.attemptsUsed} / {r.maxAttemptsTotal} + + {r.exhausted && + + Exhausted + } + + {r.lastAttemptOn + ? dayjs(r.lastAttemptOn).format("YYYY-MM-DD HH:mm") + : never failed} + + {r.resolvedHandlerId + ? {r.resolvedHandlerId} + : Nothing} + + {r.resolvedFrom + ? + {retryAlertLevelLabels[r.resolvedFrom]} + + : r.silencedAt + ? {silenceLabels[r.silencedAt]} + : Not configured} + + {r.exhaustedNotifiedOn + ? + {dayjs(r.exhaustedNotifiedOn).format("YYYY-MM-DD HH:mm")} + + : r.exhausted && r.resolvedHandlerId + ? never alerted + : } + + + {/* Text links, not the standard Button: its min-height + and margins set the row height, and chunky buttons on + every row cost more vertical space than the rows + themselves. Labels stay spelled out — the icon-only + actions used elsewhere are guesswork to a reader. */} +
+ {/* Nothing to clear until the pair has actually failed. */} + {r.lastAttemptOn && + } + +
+
+
+
} + +
+ {/* Counts the filtered set, not every row, so "total" matches what is listed. */} + setView({offset: e.offset, limit: e.limit})}/> + + + +
+ } +
+ ); +} + +export default RetryPolicySubscriptions; diff --git a/src/components/RetryPolicy.tsx b/src/components/RetryPolicy.tsx index 0504436..6e2a72d 100644 --- a/src/components/RetryPolicy.tsx +++ b/src/components/RetryPolicy.tsx @@ -5,16 +5,18 @@ import FormField from "src/components/common/forms/FormField"; import TextEditor from "src/components/common/forms/TextEditor"; import Authorize from "src/components/common/authorize/authorize"; import React, {useEffect, useState} from "react"; -import {RetryPolicyModel} from "src/types/retryPolicies"; +import {RetryPolicyModel, pairsFromRecord, recordFromPairs} from "src/types/retryPolicies"; import RetryGroupsEditor from "src/components/RetryPolicies/RetryGroupsEditor"; -import RetryBudgetUsage from "src/components/RetryPolicies/RetryBudgetUsage"; +import RetryPolicySubscriptions from "src/components/RetryPolicies/RetryPolicySubscriptions"; import TestRetryPolicyModal from "src/components/RetryPolicies/TestRetryPolicyModal"; +import AdapterEditor from "src/components/Subscriptions/AdapterEditor"; import {MdPlayCircleOutline} from "react-icons/md"; const RetryPolicy = () => { const nav = useNavigate() const [data, setData] = useState() + const [saved, setSaved] = useState() const [testModalVisible, setTestModalVisible] = useState(false) const {id} = useParams() as { id: string } const [fetch] = useLazyRetryPolicyQuery() @@ -24,13 +26,18 @@ const RetryPolicy = () => { const result = await fetch(Number(id)) if (result.isSuccess) { setData({...result.data, id: Number(id)}) + setSaved({...result.data, id: Number(id)}) } } - const onUpdate = () => { - if (data) update({...data, id: Number(id)}) + const onUpdate = async () => { + if (!data) return + await update({...data, id: Number(id)}) + setSaved(data) } + const onDiscard = () => setData(saved) + useEffect(() => { fetchData() }, [id]); @@ -39,10 +46,15 @@ const RetryPolicy = () => { setData((d) => ({...d, [key]: value} as RetryPolicyModel)) } + const changed = !!data && !!saved && JSON.stringify(data) !== JSON.stringify(saved) + if (!data) return <>; - return
+ // Everything on one page and all three tables on screen at once: the two config blocks share a + // row so the width beside them is not wasted, and the subscriptions table takes the full width + // below with its own scroll, so the layout never moves as rows pile up. + return
{testModalVisible && setTestModalVisible(false)}/>} @@ -58,22 +70,60 @@ const RetryPolicy = () => {
+ {/* Groups spans the width: eight columns need it, and it is only a few rows tall. */}
onChange("groups", g)}/> + onChange={(g) => onChange("groups", g)} + policyAlertHandlerId={data.alertHandlerId} + policyAlertHandlerProperties={data.alertHandlerProperties}/>
-
- + {/* The alert card is tall — one row per handler property — so it sits beside the one other + tall thing on the page rather than beside the short Groups card, where it left a gap. + Neither card is height-capped: a form that scrolls inside a box reads as broken, while a + long data table that scrolls is ordinary, so only the table below caps itself. */} +
+
+ +
+ +
+ +

+ Used unless a group or a single subscription overrides it. Empty sends nothing. +

+ onChange("alertHandlerId", v)} + props={pairsFromRecord(data.alertHandlerProperties)} + onPropsChange={(p) => onChange("alertHandlerProperties", recordFromPairs(p))}/> +
+
+ {/* Cancel stays put; Save lives only in the bar below so it is never in two places at once. */}
- - - -
+ + {/* Appears only once the policy actually differs from what is stored, and then follows the + page, so an edit made at the top cannot be forgotten while reading the bottom. Resets and + alert overrides are not here: those apply the moment they are clicked. */} + {changed && +
+ Unsaved changes to this policy. +
+ + + + +
+
} +
} diff --git a/src/types/retryPolicies.ts b/src/types/retryPolicies.ts index 9562940..f0c04ff 100644 --- a/src/types/retryPolicies.ts +++ b/src/types/retryPolicies.ts @@ -1,4 +1,4 @@ -import {OptionType} from "./common"; +import {KeyValuePair, OptionType} from "./common"; export enum XchangeResultType { Success = "Success", @@ -73,6 +73,22 @@ export interface RetryBudget { delayStrategy: DelayStrategy } +// Whether a level of the alert hierarchy defines its own destination for budget-exhausted +// alerts or defers upward. An overriding level REPLACES the level above it rather than merging, +// so whichever level wins must carry the handler and all of its properties. +export enum RetryAlertMode { + Inherit = "Inherit", + Send = "Send", + Silent = "Silent", +} + +// Which level of the hierarchy decided where an alert goes. +export enum RetryAlertLevel { + SubscriptionGroup = "SubscriptionGroup", + Group = "Group", + Policy = "Policy", +} + export interface RetryGroup { id?: string name: string @@ -83,12 +99,18 @@ export interface RetryGroup { action?: RetryAction budget?: RetryBudget | null notes?: string | null + alertMode?: RetryAlertMode + alertHandlerId?: string | null + alertHandlerProperties?: Record | null } export interface RetryPolicyModel { id?: number name: string groups: RetryGroup[] + // The policy default, inherited by every group that does not override it. + alertHandlerId?: string | null + alertHandlerProperties?: Record | null } export interface RetryPolicyRow { @@ -97,8 +119,14 @@ export interface RetryPolicyRow { groupCount: number } -// "Max attempts total" is counted per integration, so a policy shared by several -// integrations reports one row for each that has spent any of its budget. +// The whole state of one subscription-and-group pair under a policy: what it has spent of the +// group's "max attempts total", and where its budget-exhausted alert goes. Both halves are keyed by +// the same pair, so they belong on one row — the question asked about an exhausted budget is +// whether anyone was told, and splitting that leaves the reader matching two tables by eye. +// +// Every pair gets a row, including subscriptions that have never failed, because an alert override +// has to be settable before the first failure. Groups that cannot exhaust a budget (Block, or no +// budget at all) are left out — they can never alert, so offering to configure one would mislead. export interface RetryGroupUsageRow { subscriptionId: number subscriptionName: string @@ -107,7 +135,23 @@ export interface RetryGroupUsageRow { attemptsUsed: number maxAttemptsTotal: number exhausted: boolean - lastAttemptOn: string + // Null when this pair has never failed — which is also how we know it has no counter to reset. + lastAttemptOn?: string | null + // When the alert was raised. Null while the budget still has room — or when it ran out before + // alerts existed. Delivery success is recorded separately, on the exchange's notifications. + exhaustedNotifiedOn?: string | null + // This pair's own override mode. "Inherit" when no override exists. + alertMode: RetryAlertMode + overrideHandlerId?: string | null + overrideHandlerProperties?: Record | null + // Where the alert actually goes, and which level decided that. Null when nothing sends. + resolvedHandlerId?: string | null + // The winning level's own settings, so an override can start from what is currently sent. + resolvedHandlerProperties?: Record | null + resolvedFrom?: RetryAlertLevel | null + // Which level switched the alert off, when one did. Nothing resolving and something being + // deliberately silenced look identical otherwise, and one is a decision, the other an oversight. + silencedAt?: RetryAlertLevel | null } export interface RetryPolicyResetUsage { @@ -115,6 +159,34 @@ export interface RetryPolicyResetUsage { groupId?: string } +export interface RetryAlertOverrideSave { + subscriptionId: number + groupId: string + alertMode: RetryAlertMode + alertHandlerId?: string | null + alertHandlerProperties?: Record | null +} + +export const retryAlertModeOptions: OptionType[] = [ + {id: RetryAlertMode.Inherit, title: "Inherit"}, + {id: RetryAlertMode.Send, title: "Send via…"}, + {id: RetryAlertMode.Silent, title: "Silent"}, +] + +export const retryAlertLevelLabels: Record = { + [RetryAlertLevel.SubscriptionGroup]: "This subscription", + [RetryAlertLevel.Group]: "Group", + [RetryAlertLevel.Policy]: "Policy", +} + +// AdapterEditor works in {key, value} pairs while the API stores a plain object, so convert +// at that boundary rather than storing pairs and having to explain the shape everywhere else. +export const pairsFromRecord = (record?: Record | null): KeyValuePair[] => + Object.entries(record ?? {}).map(([key, value]) => ({key, value})); + +export const recordFromPairs = (pairs?: KeyValuePair[]): Record => + Object.fromEntries((pairs ?? []).map(p => [p.key, p.value])); + export interface RetryPoliciesSearchModel { limit?: number offset?: number From 7c09b225d75774b6df2ab750eed49fdca0ab6c85 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 13:33:41 +0300 Subject: [PATCH 3/4] Show a subscription's retry failures in an expandable row --- src/client/apis/retryPoliciesApi.ts | 13 ++ .../RetryPolicies/RetryGroupAttempts.tsx | 116 ++++++++++++++++++ .../RetryPolicySubscriptions.tsx | 38 +++++- src/types/retryPolicies.ts | 28 +++++ 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 src/components/RetryPolicies/RetryGroupAttempts.tsx diff --git a/src/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index dfb0326..973d226 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -5,6 +5,8 @@ import { RetryPolicyModel, RetryPolicyRow, RetryGroupUsageRow, + RetryGroupAttempts, + RetryGroupAttemptsRequest, RetryPolicyResetUsage, TestRetryPolicyRequest, TestRetryPolicyResponse, @@ -82,6 +84,16 @@ export const RetryPoliciesApi = createApi({ body: {} }) }), + // Cached per pair and left alone by resets and override saves: neither changes which + // failures happened. Only a retry actually running would, and polling every open row for + // that costs more than it tells anyone. + retryPolicyAttempts: builder.query({ + query: ({id, ...body}) => ({ + url: `RetryPolicies/${id}/attempts`, + method: "POST", + body + }) + }), resetRetryPolicyUsage: builder.mutation<{}, { id: number } & RetryPolicyResetUsage>({ invalidatesTags: ['retryPolicyUsage'], query: ({id, ...body}) => ({ @@ -112,6 +124,7 @@ export const { useRetryPoliciesLookupQuery, useTestRetryPolicyMutation, useRetryPolicyUsageQuery, + useRetryPolicyAttemptsQuery, useResetRetryPolicyUsageMutation, useSaveRetryAlertOverrideMutation, } = RetryPoliciesApi; diff --git a/src/components/RetryPolicies/RetryGroupAttempts.tsx b/src/components/RetryPolicies/RetryGroupAttempts.tsx new file mode 100644 index 0000000..4f12bea --- /dev/null +++ b/src/components/RetryPolicies/RetryGroupAttempts.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import {NavLink} from "react-router-dom"; +import dayjs from "dayjs"; +import {useRetryPolicyAttemptsQuery} from "src/client/apis/retryPoliciesApi"; +import {RetryGroupAttemptRow} from "src/types/retryPolicies"; +import Button from "src/components/common/forms/Button"; + +interface Props { + policyId: number + subscriptionId: number + groupId: string +} + +// The failures behind one row's spent budget. Mounted only while its row is open, which is what +// makes the fetch lazy — no policy loads more of these than the reader actually asked to see. + +// Exceptions arrive whole, stack trace and all. The message is the first line; the rest is left to +// the title attribute and to the exchange itself. +const oneLine = (text?: string | null) => { + const first = (text ?? "").split("\n")[0].trim() + return first || "No error text recorded" +} + +const Attempt: React.FC<{ attempt: RetryGroupAttemptRow }> = ({attempt}) => ( +
+ + {dayjs(attempt.failedOn).format("YYYY-MM-DD HH:mm:ss")} + + {/* Counted from 1 for the first delivery, matching what the Test policy dialog shows — + the stored number is the retry chain's depth, which starts at 0. */} + + {attempt.attemptNumber != null ? `#${attempt.attemptNumber + 1}` : "—"} + + + {oneLine(attempt.exception)} + + {attempt.retryBlockedReason && + + {attempt.retryBlockedReason} + } + + Open + +
+) + +const RetryGroupAttempts: React.FC = ({policyId, subscriptionId, groupId}) => { + + const {data, isLoading, isError, refetch} = + useRetryPolicyAttemptsQuery({id: policyId, subscriptionId, groupId}) + + if (isLoading) + return

Loading failures…

+ + if (isError) + return ( +
+ Could not load this pair's failures. + +
+ ) + + const attempts = data?.attempts ?? [] + + // One message covers all three ways of getting here — never failed, failed only before + // attempts were tracked, or reset since (a reset drops the counter row, not the failures). An + // empty box on its own would read as a bug. + if (attempts.length === 0) + return ( +

+ No failures recorded against this group for this subscription. Failures from before + retry attempts were tracked are not listed here. +

+ ) + + const pending = attempts.filter(a => a.retryPending) + const stopped = attempts.filter(a => !a.retryPending) + + return ( +
+ {/* Still moving first: these are the ones an operator can still affect. The server + orders them first too, so the cap can never drop them. */} + {pending.length > 0 && +
+

+ Retrying now + {pending.length} +

+ {pending.map(a => )} +
} + + {stopped.length > 0 && +
+

+ Stopped + {stopped.length} +

+ {stopped.map(a => )} +
} + + {/* Only when there is more than is shown: a count that always appears reads as noise, + and the total counts every failure ever, not what the budget currently says. */} + {data && data.total > attempts.length && +

+ Showing the latest {attempts.length} of {data.total} failures. +

} +
+ ) +} + +export default RetryGroupAttempts; diff --git a/src/components/RetryPolicies/RetryPolicySubscriptions.tsx b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx index 58b7f26..ecce9bf 100644 --- a/src/components/RetryPolicies/RetryPolicySubscriptions.tsx +++ b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx @@ -1,5 +1,6 @@ import React, {useState} from "react"; import dayjs from "dayjs"; +import {MdExpandMore, MdChevronRight} from "react-icons/md"; import { useResetRetryPolicyUsageMutation, useRetryPolicyUsageQuery, @@ -14,6 +15,7 @@ import {DataListViewSettingsEditor} from "src/components/common/DataListViewSett import Modal from "src/components/common/Modal"; import Authorize from "src/components/common/authorize/authorize"; import RetryAlertEditor, {RetryAlertValue} from "src/components/RetryPolicies/RetryAlertEditor"; +import RetryGroupAttempts from "src/components/RetryPolicies/RetryGroupAttempts"; import { RetryAlertLevel, RetryAlertMode, @@ -83,6 +85,8 @@ const silenceLabels: Record = { // Shared by the header and body cells so columns stay aligned as padding is tuned. const cell = "px-3 py-1.5 whitespace-nowrap" +const keyOf = (row: RetryGroupUsageRow) => `${row.subscriptionId}-${row.groupId}` + const RetryPolicySubscriptions: React.FC = ({policyId}) => { const {data, isLoading, isError, refetch} = useRetryPolicyUsageQuery(policyId) @@ -92,6 +96,9 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { const [filterId, setFilterId] = useState("attention") const [search, setSearch] = useState("") const [view, setView] = useState({offset: 0, limit: 10}) + // One open at a time: the panel adds rows below whichever row it belongs to, and several open + // at once would push the rest of the table around unpredictably as each one loads. + const [openKey, setOpenKey] = useState() const [editing, setEditing] = useState() const [draft, setDraft] = useState() @@ -219,6 +226,7 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { + @@ -230,8 +238,23 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { - {visible.map((r) => ( - + {visible.map((r) => { + const rowKey = keyOf(r) + const open = openKey === rowKey + return + + {/* What the spent budget went on, one row at a time. Shown on + every row, including ones with nothing spent: a reset drops + the counter but not the failures, so "nothing spent" is no + promise that there is nothing to see. */} + @@ -304,7 +327,16 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { - ))} + {open && + + + } + + })}
Subscription Group Used
+ + {r.subscriptionName}
+ +
} diff --git a/src/types/retryPolicies.ts b/src/types/retryPolicies.ts index f0c04ff..0780535 100644 --- a/src/types/retryPolicies.ts +++ b/src/types/retryPolicies.ts @@ -154,6 +154,34 @@ export interface RetryGroupUsageRow { silencedAt?: RetryAlertLevel | null } +// What a row's spent budget was actually spent on. Fetched a pair at a time, only when a row is +// opened: a policy with fifty subscriptions would otherwise load fifty of these to answer a +// question about one of them. +export interface RetryGroupAttempts { + // Every failure this group has caught for this subscription, of which `attempts` carries the + // latest few. Not the budget counter, which is reset while the failures stay. + total: number + attempts: RetryGroupAttemptRow[] +} + +export interface RetryGroupAttemptRow { + xchangeId: string + // How deep the retry chain was, 0 being the first delivery. Null on failures recorded before + // the number was stored. + attemptNumber?: number | null + failedOn: string + exception?: string | null + // The only field here that is not history: true while another attempt is still scheduled. + retryPending: boolean + // Why the policy refused another attempt, when it refused. + retryBlockedReason?: string | null +} + +export interface RetryGroupAttemptsRequest { + subscriptionId: number + groupId: string +} + export interface RetryPolicyResetUsage { subscriptionId?: number groupId?: string From 532b6d70a85451d0446c41ae51bec1e7e9583f6c Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 16:30:47 +0300 Subject: [PATCH 4/4] Stop failed writes from looking like successes, and confirm resets --- src/client/apis/apiError.ts | 19 ++++++ .../RetryPolicies/AddEditRetryGroupModal.tsx | 27 ++++++-- .../RetryPolicies/RetryAlertEditor.tsx | 15 +++++ .../RetryPolicies/RetryGroupAttempts.tsx | 2 +- .../RetryPolicySubscriptions.tsx | 65 ++++++++++++++----- src/components/RetryPolicy.tsx | 17 ++++- 6 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 src/client/apis/apiError.ts diff --git a/src/client/apis/apiError.ts b/src/client/apis/apiError.ts new file mode 100644 index 0000000..54ce6dd --- /dev/null +++ b/src/client/apis/apiError.ts @@ -0,0 +1,19 @@ +// A rejected write comes back as either a validation map — { CODE: ["what went wrong"] } — or a +// problem detail. This pulls the first human sentence out of whichever it is. +// +// The middleware already toasts failures, but it toasts the raw JSON, which tells a reader nothing +// they can act on. A form that knows why its save was refused can say so where the reader is +// looking. +export const apiErrorMessage = (error: unknown, fallback: string): string => { + const data = (error as { data?: unknown } | undefined)?.data; + + if (typeof data === "string" && data.trim()) return data; + + if (data && typeof data === "object") + for (const value of Object.values(data as Record)) { + if (typeof value === "string" && value.trim()) return value; + if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim()) return value[0]; + } + + return fallback; +}; diff --git a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx index c479875..de17106 100644 --- a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx +++ b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx @@ -7,7 +7,7 @@ import {ChoiceEditor} from "src/components/common/forms/ChoiceEditor"; import FieldTooltip from "src/components/common/forms/FieldTooltip"; import MatchersEditor from "src/components/RetryPolicies/MatchersEditor"; import {OptionType} from "src/types/common"; -import RetryAlertEditor from "src/components/RetryPolicies/RetryAlertEditor"; +import RetryAlertEditor, {alertIsIncomplete} from "src/components/RetryPolicies/RetryAlertEditor"; import { DelayStrategy, RetryAction, @@ -124,6 +124,20 @@ const AddEditRetryGroupModal: React.FC = ({ })); } + // A blocking group has no budget to exhaust, so it can never alert. Clearing the alert as the + // action changes stops a group from carrying settings its own action puts out of reach — the + // usage report leaves such groups out entirely, so nothing would ever show them again. + const onChangeAction = (action: RetryAction) => { + setGroup((g) => action === RetryAction.Block + ? { + ...g, action, + alertMode: RetryAlertMode.Inherit, + alertHandlerId: null, + alertHandlerProperties: null + } + : {...g, action}); + } + const onSubmit = () => { onAdd(group); onClose(); @@ -131,7 +145,8 @@ const AddEditRetryGroupModal: React.FC = ({ if (!visible) return null; - return + return
onChangeField("name", v)}/> @@ -160,7 +175,7 @@ const AddEditRetryGroupModal: React.FC = ({ onChangeField("action", v)} + onChange={(v) => onChangeAction(v as RetryAction)} options={retryActionOptions} optionTitle={(o: OptionType) => o.title} optionValue={(o: OptionType) => o.id} @@ -237,7 +252,9 @@ const AddEditRetryGroupModal: React.FC = ({
)} -
+ {/* Only a group that allows retries can spend a budget, so only it can exhaust one and + alert. Offering the section for a blocking group would promise an alert that cannot fire. */} + {group.action === RetryAction.Allow &&
= ({ inheritedFrom={"the policy default"} inheritedHandlerId={policyAlertHandlerId} inheritedProperties={policyAlertHandlerProperties}/> -
+
} onChangeField("notes", v)}/> diff --git a/src/components/RetryPolicies/RetryAlertEditor.tsx b/src/components/RetryPolicies/RetryAlertEditor.tsx index db08647..eb614f5 100644 --- a/src/components/RetryPolicies/RetryAlertEditor.tsx +++ b/src/components/RetryPolicies/RetryAlertEditor.tsx @@ -23,6 +23,17 @@ interface Props { title?: string } +/** + * True when this level claims to send but names nothing to send with. Both callers check it before + * saving: the API refuses this too, so without the check the save fails for a reason the form never + * got to explain. + */ +export const alertIsIncomplete = ( + // Widened rather than taking RetryAlertValue: a group carries the same two fields with alertMode + // optional, and both levels ask this same question. + value: { alertMode?: RetryAlertMode, alertHandlerId?: string | null }) => + (value.alertMode ?? RetryAlertMode.Inherit) === RetryAlertMode.Send && !value.alertHandlerId; + const modes: { mode: RetryAlertMode, label: string }[] = [ {mode: RetryAlertMode.Inherit, label: "Inherit"}, {mode: RetryAlertMode.Send, label: "Send via…"}, @@ -97,6 +108,10 @@ const RetryAlertEditor: React.FC = ({ Copy from {inheritedFrom} } + {!value.alertHandlerId && +

+ Choose a handler — until then this level sends nothing, and saving is blocked. +

} onChange({...value, alertHandlerId: v})} diff --git a/src/components/RetryPolicies/RetryGroupAttempts.tsx b/src/components/RetryPolicies/RetryGroupAttempts.tsx index 4f12bea..3b85ed4 100644 --- a/src/components/RetryPolicies/RetryGroupAttempts.tsx +++ b/src/components/RetryPolicies/RetryGroupAttempts.tsx @@ -39,7 +39,7 @@ const Attempt: React.FC<{ attempt: RetryGroupAttemptRow }> = ({attempt}) => ( title={attempt.retryBlockedReason}> {attempt.retryBlockedReason} } - Open diff --git a/src/components/RetryPolicies/RetryPolicySubscriptions.tsx b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx index ecce9bf..fcaa1fa 100644 --- a/src/components/RetryPolicies/RetryPolicySubscriptions.tsx +++ b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx @@ -14,7 +14,9 @@ import TabNavigator from "src/components/common/forms/TabNavigator"; import {DataListViewSettingsEditor} from "src/components/common/DataListViewSettingsEditor"; import Modal from "src/components/common/Modal"; import Authorize from "src/components/common/authorize/authorize"; -import RetryAlertEditor, {RetryAlertValue} from "src/components/RetryPolicies/RetryAlertEditor"; +import RetryAlertEditor, {RetryAlertValue, alertIsIncomplete} from "src/components/RetryPolicies/RetryAlertEditor"; +import Dialog from "src/components/common/dialog"; +import {apiErrorMessage} from "src/client/apis/apiError"; import RetryGroupAttempts from "src/components/RetryPolicies/RetryGroupAttempts"; import { RetryAlertLevel, @@ -101,6 +103,8 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { const [openKey, setOpenKey] = useState() const [editing, setEditing] = useState() const [draft, setDraft] = useState() + const [error, setError] = useState() + const [confirmResetAll, setConfirmResetAll] = useState(false) const rows = data ?? [] const filter = filters.find(f => f.id === filterId) ?? filters[0] @@ -129,16 +133,34 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { }) } + // Unwrapped, because a mutation trigger reports failure in its result rather than by throwing: + // closing the dialog on a refused save would throw away what was typed and read as stored. const onSubmit = async () => { if (!editing || !draft) return - await save({ - id: policyId, - subscriptionId: editing.subscriptionId, - groupId: editing.groupId, - ...draft - }) - setEditing(undefined) - setDraft(undefined) + try { + await save({ + id: policyId, + subscriptionId: editing.subscriptionId, + groupId: editing.groupId, + ...draft + }).unwrap() + setEditing(undefined) + setDraft(undefined) + setError(undefined) + } catch (e) { + setError(apiErrorMessage(e, "Could not save this override.")) + } + } + + // Clearing a counter cannot be undone, so a refusal has to be visible rather than looking like + // a reset that simply had nothing to clear. + const onReset = async (subscriptionId?: number, groupId?: string) => { + try { + await reset({id: policyId, subscriptionId, groupId}).unwrap() + setError(undefined) + } catch (e) { + setError(apiErrorMessage(e, "Could not reset the spent budget.")) + } } // What this pair would fall back to if its own override were removed — the group's setting when @@ -155,8 +177,23 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { + {confirmResetAll && + setConfirmResetAll(false)} + onConfirm={async () => { + setConfirmResetAll(false) + await onReset() + }}/>} + + {error && +

+ {error} +

} + {editing && draft && - setEditing(undefined)} submitLabel={"Save"} onSubmit={onSubmit}> + setEditing(undefined)} submitLabel={"Save"} onSubmit={onSubmit} + submitDisabled={alertIsIncomplete(draft)}>

{editing.subscriptionName} {" — "} @@ -311,11 +348,7 @@ const RetryPolicySubscriptions: React.FC = ({policyId}) => { {r.lastAttemptOn && } diff --git a/src/components/RetryPolicy.tsx b/src/components/RetryPolicy.tsx index 6e2a72d..ce965b9 100644 --- a/src/components/RetryPolicy.tsx +++ b/src/components/RetryPolicy.tsx @@ -6,6 +6,7 @@ import TextEditor from "src/components/common/forms/TextEditor"; import Authorize from "src/components/common/authorize/authorize"; import React, {useEffect, useState} from "react"; import {RetryPolicyModel, pairsFromRecord, recordFromPairs} from "src/types/retryPolicies"; +import {apiErrorMessage} from "src/client/apis/apiError"; import RetryGroupsEditor from "src/components/RetryPolicies/RetryGroupsEditor"; import RetryPolicySubscriptions from "src/components/RetryPolicies/RetryPolicySubscriptions"; import TestRetryPolicyModal from "src/components/RetryPolicies/TestRetryPolicyModal"; @@ -17,6 +18,7 @@ const RetryPolicy = () => { const nav = useNavigate() const [data, setData] = useState() const [saved, setSaved] = useState() + const [saveError, setSaveError] = useState() const [testModalVisible, setTestModalVisible] = useState(false) const {id} = useParams() as { id: string } const [fetch] = useLazyRetryPolicyQuery() @@ -30,10 +32,17 @@ const RetryPolicy = () => { } } + // A mutation trigger resolves with an error rather than throwing, so without unwrap a refused + // save would still clear the unsaved-changes bar and read as stored. const onUpdate = async () => { if (!data) return - await update({...data, id: Number(id)}) - setSaved(data) + try { + await update({...data, id: Number(id)}).unwrap() + setSaved(data) + setSaveError(undefined) + } catch (e) { + setSaveError(apiErrorMessage(e, "Could not save this policy.")) + } } const onDiscard = () => setData(saved) @@ -112,7 +121,9 @@ const RetryPolicy = () => { alert overrides are not here: those apply the moment they are clicked. */} {changed &&

- Unsaved changes to this policy. + + {saveError ?? "Unsaved changes to this policy."} +