diff --git a/src/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index a9b8002..deea538 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -4,6 +4,8 @@ import { RetryPoliciesSearchModel, RetryPolicyModel, RetryPolicyRow, + RetryGroupUsageRow, + RetryPolicyResetUsage, TestRetryPolicyRequest, TestRetryPolicyResponse } from "src/types/retryPolicies"; @@ -12,7 +14,7 @@ import {ApiPagedResponse} from "src/types/common"; export const RetryPoliciesApi = createApi({ baseQuery: customFetchBase, reducerPath: "RetryPoliciesApi", - tagTypes: ["retryPolicies"], + tagTypes: ["retryPolicies", "retryPolicyUsage"], endpoints: (builder) => ({ retryPolicies: builder.query, RetryPoliciesSearchModel>({ providesTags: ['retryPolicies'], @@ -40,7 +42,8 @@ export const RetryPoliciesApi = createApi({ }) }), updateRetryPolicy: builder.mutation<{}, { id: number } & RetryPolicyModel>({ - invalidatesTags: ['retryPolicies'], + // Saving drops the counters of any removed group, so the usage panel must refetch. + invalidatesTags: ['retryPolicies', 'retryPolicyUsage'], query: body => ({ url: `RetryPolicies/${body.id}`, method: "POST", @@ -68,6 +71,22 @@ export const RetryPoliciesApi = createApi({ method: "POST", body }) + }), + retryPolicyUsage: builder.query({ + providesTags: ['retryPolicyUsage'], + query: id => ({ + url: `RetryPolicies/${id}/usage`, + method: "POST", + body: {} + }) + }), + resetRetryPolicyUsage: builder.mutation<{}, { id: number } & RetryPolicyResetUsage>({ + invalidatesTags: ['retryPolicyUsage'], + query: ({id, ...body}) => ({ + url: `RetryPolicies/${id}/resetusage`, + method: "POST", + body + }) }) }) }); @@ -81,4 +100,6 @@ export const { useDeleteRetryPolicyMutation, useRetryPoliciesLookupQuery, useTestRetryPolicyMutation, + useRetryPolicyUsageQuery, + useResetRetryPolicyUsageMutation, } = RetryPoliciesApi; diff --git a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx index ffc741d..d50a2a1 100644 --- a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx +++ b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx @@ -169,7 +169,7 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi onChangeBudgetField("maxAttemptsPerError", Number(v))}/> - + onChangeBudgetField("maxAttemptsTotal", Number(v))}/> diff --git a/src/components/RetryPolicies/RetryBudgetUsage.tsx b/src/components/RetryPolicies/RetryBudgetUsage.tsx new file mode 100644 index 0000000..04015c9 --- /dev/null +++ b/src/components/RetryPolicies/RetryBudgetUsage.tsx @@ -0,0 +1,113 @@ +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/RetryPolicy.tsx b/src/components/RetryPolicy.tsx index c772a45..0504436 100644 --- a/src/components/RetryPolicy.tsx +++ b/src/components/RetryPolicy.tsx @@ -7,6 +7,7 @@ import Authorize from "src/components/common/authorize/authorize"; import React, {useEffect, useState} from "react"; import {RetryPolicyModel} from "src/types/retryPolicies"; import RetryGroupsEditor from "src/components/RetryPolicies/RetryGroupsEditor"; +import RetryBudgetUsage from "src/components/RetryPolicies/RetryBudgetUsage"; import TestRetryPolicyModal from "src/components/RetryPolicies/TestRetryPolicyModal"; import {MdPlayCircleOutline} from "react-icons/md"; @@ -62,6 +63,10 @@ const RetryPolicy = () => { onChange={(g) => onChange("groups", g)}/> +
+ +
+
diff --git a/src/components/exchanges/ExchangeList.tsx b/src/components/exchanges/ExchangeList.tsx index 63ca478..93a599f 100644 --- a/src/components/exchanges/ExchangeList.tsx +++ b/src/components/exchanges/ExchangeList.tsx @@ -224,6 +224,7 @@ export const ExchangeList: React.FC = ({ xid={showExceptionFor} exception={data.find((i) => i.id == showExceptionFor)?.exception} scheduledRetryOn={data.find((i) => i.id == showExceptionFor)?.scheduledRetryOn} + retryBlockedReason={data.find((i) => i.id == showExceptionFor)?.retryBlockedReason} onClose={() => setShowExceptionFor(null)} onRefresh={refresh} /> diff --git a/src/components/exchanges/RetryModal.tsx b/src/components/exchanges/RetryModal.tsx index c541541..e819264 100644 --- a/src/components/exchanges/RetryModal.tsx +++ b/src/components/exchanges/RetryModal.tsx @@ -11,9 +11,10 @@ type Props = { onClose: () => void xid?: string scheduledRetryOn?: string | null + retryBlockedReason?: string | null onRefresh?: () => void } -const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, onRefresh}) => { +const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, retryBlockedReason, onRefresh}) => { const [resetForRetry, setResetForRetry] = useState(false); const [runNow] = useRunDelayedRetryNowMutation(); @@ -71,6 +72,12 @@ const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, Use "Run Now" to execute it immediately, or wait for it to run automatically.
} + { + !hasScheduledRetry && retryBlockedReason && +
+ Not retried automatically — {retryBlockedReason}. Use "Retry" to run it manually. +
+ } { exception &&
diff --git a/src/types/retryPolicies.ts b/src/types/retryPolicies.ts index d123b50..9562940 100644 --- a/src/types/retryPolicies.ts +++ b/src/types/retryPolicies.ts @@ -97,6 +97,24 @@ 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. +export interface RetryGroupUsageRow { + subscriptionId: number + subscriptionName: string + groupId: string + groupName: string + attemptsUsed: number + maxAttemptsTotal: number + exhausted: boolean + lastAttemptOn: string +} + +export interface RetryPolicyResetUsage { + subscriptionId?: number + groupId?: string +} + export interface RetryPoliciesSearchModel { limit?: number offset?: number diff --git a/src/types/xchange.ts b/src/types/xchange.ts index 5737130..181a971 100644 --- a/src/types/xchange.ts +++ b/src/types/xchange.ts @@ -39,6 +39,8 @@ export interface IXchange { correlationId: string; partnerId: number | null; scheduledRetryOn?: string | null; + // Why the retry policy declined another attempt, when it declined. + retryBlockedReason?: string | null; }