Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/client/apis/apiError.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)) {
if (typeof value === "string" && value.trim()) return value;
if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim()) return value[0];
}

return fallback;
};
29 changes: 27 additions & 2 deletions src/client/apis/retryPoliciesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -80,13 +84,32 @@ 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<RetryGroupAttempts, { id: number } & RetryGroupAttemptsRequest>({
query: ({id, ...body}) => ({
url: `RetryPolicies/${id}/attempts`,
method: "POST",
body
})
}),
resetRetryPolicyUsage: builder.mutation<{}, { id: number } & RetryPolicyResetUsage>({
invalidatesTags: ['retryPolicyUsage'],
query: ({id, ...body}) => ({
url: `RetryPolicies/${id}/resetusage`,
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
})
})
})
});
Expand All @@ -101,5 +124,7 @@ export const {
useRetryPoliciesLookupQuery,
useTestRetryPolicyMutation,
useRetryPolicyUsageQuery,
useRetryPolicyAttemptsQuery,
useResetRetryPolicyUsageMutation,
useSaveRetryAlertOverrideMutation,
} = RetryPoliciesApi;
52 changes: 47 additions & 5 deletions src/components/RetryPolicies/AddEditRetryGroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +23,8 @@ type Props = {
onClose: () => void
onAdd: (group: RetryGroup) => void
initial?: RetryGroup
policyAlertHandlerId?: string | null
policyAlertHandlerProperties?: Record<string, string> | null
}

const emptyGroup = (): RetryGroup => ({
Expand All @@ -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
Expand All @@ -58,7 +63,14 @@ const defaultDelayStrategyFor = (type: string): DelayStrategy => {
}
}

const AddEditRetryGroupModal: React.FC<Props> = ({visible, onClose, onAdd, initial}) => {
const AddEditRetryGroupModal: React.FC<Props> = ({
visible,
onClose,
onAdd,
initial,
policyAlertHandlerId,
policyAlertHandlerProperties
}) => {

const [group, setGroup] = useState<RetryGroup>(initial ?? emptyGroup());

Expand Down Expand Up @@ -112,14 +124,29 @@ const AddEditRetryGroupModal: React.FC<Props> = ({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();
}

if (!visible) return null;

return <Modal onClose={onClose} submitLabel={initial ? "Save" : "Add"} onSubmit={onSubmit}>
return <Modal onClose={onClose} submitLabel={initial ? "Save" : "Add"} onSubmit={onSubmit}
submitDisabled={alertIsIncomplete(group)}>
<div className={"flex flex-row gap-5"}>
<FormField title="Name" tooltip="A label for this rule, shown in the groups list and in retry decision logs." className="grow">
<TextEditor value={group.name} onChange={(v) => onChangeField("name", v)}/>
Expand Down Expand Up @@ -148,7 +175,7 @@ const AddEditRetryGroupModal: React.FC<Props> = ({visible, onClose, onAdd, initi
<FormField title="Action" tooltip="Allow schedules a retry according to the budget below. Block hard-stops retries for this failure, even if a budget would otherwise allow one." className="grow mt-3 w-48">
<ChoiceEditor
value={group.action}
onChange={(v) => onChangeField("action", v)}
onChange={(v) => onChangeAction(v as RetryAction)}
options={retryActionOptions}
optionTitle={(o: OptionType) => o.title}
optionValue={(o: OptionType) => o.id}
Expand All @@ -169,7 +196,7 @@ const AddEditRetryGroupModal: React.FC<Props> = ({visible, onClose, onAdd, initi
<TextEditor type={"number"} value={group.budget?.maxAttemptsPerError}
onChange={(v) => onChangeBudgetField("maxAttemptsPerError", Number(v))}/>
</FormField>
<FormField title="Max attempts total" tooltip="Lifetime ceiling on retries across all messages hitting this group, counted separately for each integration. Once reached the group stops retrying for that integration until its counter is cleared." className="grow">
<FormField title="Max attempts total" tooltip="Lifetime ceiling on retries across all messages hitting this group, counted separately for each subscription. Once reached the group stops retrying for that subscription until its counter is cleared." className="grow">
<TextEditor type={"number"} value={group.budget?.maxAttemptsTotal}
onChange={(v) => onChangeBudgetField("maxAttemptsTotal", Number(v))}/>
</FormField>
Expand Down Expand Up @@ -225,6 +252,21 @@ const AddEditRetryGroupModal: React.FC<Props> = ({visible, onClose, onAdd, initi
</div>
)}

{/* 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 && <div className={"mt-5 border-t pt-3"}>
<RetryAlertEditor
value={{
alertMode: group.alertMode ?? RetryAlertMode.Inherit,
alertHandlerId: group.alertHandlerId,
alertHandlerProperties: group.alertHandlerProperties
}}
onChange={(v) => setGroup((g) => ({...g, ...v}))}
inheritedFrom={"the policy default"}
inheritedHandlerId={policyAlertHandlerId}
inheritedProperties={policyAlertHandlerProperties}/>
</div>}

<FormField title="Notes" tooltip="Optional free-text notes visible to other admins managing this policy." className="grow mt-3">
<TextEditor value={group.notes ?? ""} onChange={(v) => onChangeField("notes", v)}/>
</FormField>
Expand Down
128 changes: 128 additions & 0 deletions src/components/RetryPolicies/RetryAlertEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, string> | 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<Props> = ({
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 ?? {}
});
Comment thread
hamzahalq marked this conversation as resolved.

return (
<div className="flex flex-col gap-2">
<h3 className="text-sm tracking-wide text-gray-700 font-semibold uppercase">{title}</h3>

<div className="flex flex-row gap-2">
{modes.map(m => (
<button key={m.mode} type="button"
onClick={() => setMode(m.mode)}
className={`px-3 py-1 rounded text-sm border transition ${mode === m.mode
? "bg-primary-600 text-white border-primary-600"
: "bg-white text-gray-600 border-gray-300 hover:border-primary-400"}`}>
{m.label}
</button>
))}
</div>

{mode === RetryAlertMode.Inherit &&
<p className="text-xs text-gray-500">
{inheritedHandlerId
? <>Uses {inheritedFrom} — <span className="font-mono">{inheritedHandlerId}</span>.</>
: <>Follows {inheritedFrom}, which currently sends no alert.</>}
</p>}

{mode === RetryAlertMode.Silent &&
<p className="text-xs text-gray-500">
Sends nothing, even when {inheritedFrom} defines an alert.
</p>}

{mode === RetryAlertMode.Send && <>
<div className="flex flex-row items-center justify-between gap-2">
<p className="text-xs text-gray-500">
Replaces {inheritedFrom} entirely — set the handler and all of its properties here.
</p>
{inheritedHandlerId &&
<button type="button" onClick={copyFromInherited}
className="shrink-0 text-xs text-primary-600 hover:underline">
Copy from {inheritedFrom}
</button>}
</div>
{!value.alertHandlerId &&
<p className="text-xs text-amber-700">
Choose a handler — until then this level sends nothing, and saving is blocked.
</p>}
<AdapterEditor title="Alert handler" type="handlers"
value={value.alertHandlerId}
onChange={(v) => onChange({...value, alertHandlerId: v})}
props={pairsFromRecord(value.alertHandlerProperties)}
onPropsChange={(p) => onChange({
...value,
alertHandlerProperties: recordFromPairs(p)
})}/>
</>}
</div>
);
}

export default RetryAlertEditor;
Loading
Loading