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/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index deea538..973d226 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -5,9 +5,12 @@ import { RetryPolicyModel, RetryPolicyRow, RetryGroupUsageRow, + RetryGroupAttempts, + RetryGroupAttemptsRequest, RetryPolicyResetUsage, TestRetryPolicyRequest, - TestRetryPolicyResponse + TestRetryPolicyResponse, + RetryAlertOverrideSave } from "src/types/retryPolicies"; import {ApiPagedResponse} from "src/types/common"; @@ -42,7 +45,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}`, @@ -80,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}) => ({ @@ -87,6 +101,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 + }) }) }) }); @@ -101,5 +124,7 @@ export const { useRetryPoliciesLookupQuery, useTestRetryPolicyMutation, useRetryPolicyUsageQuery, + useRetryPolicyAttemptsQuery, useResetRetryPolicyUsageMutation, + useSaveRetryAlertOverrideMutation, } = RetryPoliciesApi; diff --git a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx index d50a2a1..de17106 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, {alertIsIncomplete} 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()); @@ -112,6 +124,20 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi })); } + // 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(); @@ -119,7 +145,8 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi if (!visible) return null; - return + return
onChangeField("name", v)}/> @@ -148,7 +175,7 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi onChangeField("action", v)} + onChange={(v) => onChangeAction(v as RetryAction)} options={retryActionOptions} optionTitle={(o: OptionType) => o.title} optionValue={(o: OptionType) => o.id} @@ -169,7 +196,7 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi onChangeBudgetField("maxAttemptsPerError", Number(v))}/> - + onChangeBudgetField("maxAttemptsTotal", Number(v))}/> @@ -225,6 +252,21 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi
)} + {/* 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 &&
+ 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..eb614f5 --- /dev/null +++ b/src/components/RetryPolicies/RetryAlertEditor.tsx @@ -0,0 +1,128 @@ +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 +} + +/** + * 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…"}, + {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 && + } +
+ {!value.alertHandlerId && +

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

} + 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/RetryGroupAttempts.tsx b/src/components/RetryPolicies/RetryGroupAttempts.tsx new file mode 100644 index 0000000..3b85ed4 --- /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/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..fcaa1fa --- /dev/null +++ b/src/components/RetryPolicies/RetryPolicySubscriptions.tsx @@ -0,0 +1,393 @@ +import React, {useState} from "react"; +import dayjs from "dayjs"; +import {MdExpandMore, MdChevronRight} from "react-icons/md"; +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, 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, + 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 keyOf = (row: RetryGroupUsageRow) => `${row.subscriptionId}-${row.groupId}` + +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}) + // 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() + const [error, setError] = useState() + const [confirmResetAll, setConfirmResetAll] = useState(false) + + 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 + }) + } + + // 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 + 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 + // 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 ( + + + {confirmResetAll && + setConfirmResetAll(false)} + onConfirm={async () => { + setConfirmResetAll(false) + await onReset() + }}/>} + + {error && +

+ {error} +

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

+ {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) => { + 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. */} + + + + + + + + {/* 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. */} + + + + {open && + + + } + + })} + +
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..ce965b9 100644 --- a/src/components/RetryPolicy.tsx +++ b/src/components/RetryPolicy.tsx @@ -5,16 +5,20 @@ 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 {apiErrorMessage} from "src/client/apis/apiError"; 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 [saveError, setSaveError] = useState() const [testModalVisible, setTestModalVisible] = useState(false) const {id} = useParams() as { id: string } const [fetch] = useLazyRetryPolicyQuery() @@ -24,13 +28,25 @@ 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)}) + // 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 + 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) + useEffect(() => { fetchData() }, [id]); @@ -39,10 +55,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 +79,62 @@ 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 && +
+ + {saveError ?? "Unsaved changes to this policy."} + +
+ + + + +
+
} +
} 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) diff --git a/src/types/retryPolicies.ts b/src/types/retryPolicies.ts index 9562940..0780535 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,51 @@ 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 +} + +// 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 { @@ -115,6 +187,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