Skip to content

Commit e77bab1

Browse files
nachoddyyyyyyyan
andauthored
SEP-1845: Report failed UI actions in SEP's own component tree so a refused action is never silent (#1388)
Stacked on #1387 (SEP-1844), which this branch is based on. Retarget to `main` once that merges. ## Why SEP-1838 made every state-changing API route refuse a non-admin with a 403 and a JSON reason, turning a rare failure into a routine one. An audit of the 47 mutation call sites in the frontend found: * 9 that surface nothing at all on failure * 2 that keep the server's message only for HTTP 400 and substitute a generic string otherwise * 6 that report only through a notistack toast The toast family depends on a `SnackbarProvider` the host application mounts. In standalone SEP that is `@sep/shell`; in the PMM-embedded deployment SEP ships no frontend of its own, so that provider is not SEP's to guarantee. A toast-only report can therefore be no report at all, which is the reported symptom: a refused create leaves the form open with nothing shown. ## What changed **A shared primitive in `@sep/framework`** (`ActionErrorAlert` / `useActionError` / `actionErrorMessage`) that derives the server's own reason and renders it inline, with no notistack dependency. `useActionError` is for actions fired from a dialog that closes before the request settles; where the mutation object is in scope at the render site, its `error` goes straight to the alert. **The message is the server's own reason.** A string `detail` arrives as `ApiError.message`. A 422 carries `detail` as a per-field array that the lift skips, so the field entries are read through `parseFieldErrors` and joined; a form uses `mapSubmitError` instead, which places them on their fields. The fallback string is reached only when neither path yields text. **One signal per failure.** At every repaired site the error toast is removed rather than kept alongside the new alert. Success toasts are unaffected. **`mapSubmitError` no longer returns an empty state for non-422 errors**, so AppCreatePage, AppTaskEditPage and the SchemaDrivenApp edit form all show a persistent banner carrying the server's reason. The 422 per-field behaviour is unchanged, and a 422 whose `detail` is a string keeps that string. **`TaskHistoryTable` gained `actionError` / `onDismissActionError`.** The connected variant reports its own stop mutation; a caller owning the mutation threads its error in. The stop confirmation closes on confirm, so the alert renders above the rows the user is left looking at. This covers the stop-task sites on AppDetailPage, SnippetExecutionAccordion, DipperApp and the tasks detail page. **`AppDetailPage`'s execute confirmation** now closes in a `finally` like the adjacent delete, and reports on the page behind it. Reopening the same execute action keeps the composed chain, so a refused chained execute can be retried without rebuilding it; opening a different action still starts empty. **The two status-narrowed sites** (`SyncControl`, batch approve in `SnippetsListPage`) report on any status. Batch approve reads the reason off the hook's `raw` wrapper, since the wrapper's own message says nothing. **A guard test** walks every package for `.mutate(` / `.mutateAsync(` call sites and fails on a file with no in-tree failure path. Sites that already reported in-tree before the primitive existed are allowlisted with the mechanism each uses. Granularity is the file, not the individual call: a file that already reports one action passes even if a second unwired mutation is added to it. Tightening that would trade a mechanical check for a heuristic one, and the omissions this guard exists to catch were whole files with no failure path at all. ## Sites repaired Previously silent: AppDetailPage (stop task, delete entity from detail page), TaskHistoryTable (stop), SnippetExecutionAccordion (stop), DipperApp (stop), tasks TaskDetailPage (stop), SnippetsListPage (download, remove approval, approve). Previously toast-only: AppListPage (delete), AppDetailPage (execute, delete task), ConnectivityControl, InventoryAppNavigation (nested delete), SyncControl. The three create/edit forms are resolved by the `mapSubmitError` change. Status-narrowed: SyncControl, batch approve. ## UX trade-off worth checking in design QA Removing the error toasts is deliberate: an inline alert is less attention-grabbing than a toast, so each alert is placed where the user is actually looking after the dialog closes (above the rows for stop, on the page behind the confirm for execute/delete, above the table for the snippet row actions). The snippets page shares one alert across every row action, so each attempt clears it first. ConnectivityControl now renders a failed probe inline as well; its passing case stays a success toast. ## Out of scope Backend authorization (SEP-1838), hiding write controls from non-admins (SEP-1844), migrating the ~30 sites that already render an in-tree signal, and a central runtime mutation-error handler (rejected: it runs outside the failing component's tree, so a host-provided toast is the only thing it could render). ## Test plan * `pnpm -r test` (14 packages, all green), `pnpm typecheck`, `pnpm exec oxlint` (0 errors), `pnpm format:check`. * New tests per repaired site cover both halves: the failure renders the server's message, and the success path is unchanged. * `ActionErrorAlert.test.tsx` covers the 403 reason, the 422 per-field join, an array `detail` on a non-422 status keeping its own message, transport failures, and the fallback. * The guard was verified against a planted probe file: a new `.mutate(` call with no failure path fails the test. --------- Signed-off-by: Ignacio Durand <nachodurand@gmail.com> Co-authored-by: yyyyyyy <yan.orestes@percona.com> Co-authored-by: yyyyyyy <contact@yyyyyyyan.tech>
1 parent eb17427 commit e77bab1

42 files changed

Lines changed: 2133 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

changelog.d/SEP-1845.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Failed UI actions now report the reason inline, from SEP's own component tree, instead of relying on a toast the host application has to provide: a refused action (HTTP 403 for a non-admin), a 409, a 5xx or a network failure all render the server's own message where the action was fired. Create and edit forms show a persistent banner, confirmation dialogs close on confirm and surface the failure on the page behind them, and stop-task, delete, execute, sync, connectivity-check and snippet approve/download failures are no longer silent.

frontend/packages/api/src/errors.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,52 @@ export function parseFieldErrors(error: unknown): FieldValidationError[] {
116116
return result;
117117
}
118118

119+
/**
120+
* Recover the server's reason from a request made with ``responseType: 'blob'``.
121+
*
122+
* Axios hands back the error body in the response type it was asked for, so a
123+
* download's 403 arrives as a ``Blob`` rather than parsed JSON and
124+
* ``messageFromPayload`` cannot see its ``detail`` — leaving the synthesized
125+
* ``HTTP 403``. Read the blob, parse it, and return an ``ApiError`` carrying
126+
* the reason (and the parsed payload, so a 422's ``detail`` array is still
127+
* reachable through {@link parseFieldErrors}).
128+
*
129+
* Async by necessity — ``Blob.text()`` is a promise — so callers await it in
130+
* their own catch rather than getting it from an interceptor.
131+
*
132+
* Falls back to the unmodified error whenever the body is not a readable JSON
133+
* blob: an HTML error page, an opaque binary body, or a network failure with no
134+
* response at all.
135+
*/
136+
export async function normalizeBlobError(error: unknown): Promise<ApiError> {
137+
const apiError = normalizeAxiosError(error);
138+
const body = apiError.data;
139+
if (typeof Blob === 'undefined' || !(body instanceof Blob)) {
140+
return apiError;
141+
}
142+
143+
let payload: unknown;
144+
try {
145+
payload = JSON.parse(await body.text());
146+
} catch {
147+
return apiError;
148+
}
149+
150+
const message = messageFromPayload(payload, apiError.message);
151+
return new ApiError(
152+
{
153+
kind: apiError.kind,
154+
status: apiError.status,
155+
code: apiError.code,
156+
message,
157+
url: apiError.url,
158+
method: apiError.method,
159+
data: payload,
160+
},
161+
apiError.original,
162+
);
163+
}
164+
119165
export function normalizeAxiosError(error: unknown): ApiError {
120166
if (error instanceof ApiError) {
121167
return error;

frontend/packages/api/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export type { MintedToken } from './client';
3434
export { createQueryClient, defaultQueryClientConfig } from './queryClient';
3535

3636
// Errors
37-
export { ApiError, normalizeAxiosError, parseFieldErrors } from './errors';
37+
export { ApiError, normalizeAxiosError, normalizeBlobError, parseFieldErrors } from './errors';
3838
export type { ApiErrorDetails, ApiErrorKind, FieldValidationError } from './errors';
3939

4040
// Auth context (provider lives in @sep/shell; the context lives here so the

frontend/packages/api/tests/errors.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { describe, expect, it } from 'vitest';
19-
import { ApiError, parseFieldErrors } from '../src/errors';
19+
import { ApiError, normalizeBlobError, parseFieldErrors } from '../src/errors';
2020

2121
function http422(detail: unknown): ApiError {
2222
return new ApiError({ kind: 'http', status: 422, message: 'HTTP 422', data: { detail } });
@@ -75,3 +75,54 @@ describe('parseFieldErrors', () => {
7575
expect(parseFieldErrors('boom')).toEqual([]);
7676
});
7777
});
78+
79+
describe('normalizeBlobError', () => {
80+
function blobFailure(status: number, body: string, type = 'application/json'): ApiError {
81+
return new ApiError({
82+
kind: 'http',
83+
status,
84+
message: `HTTP ${status}`,
85+
data: new Blob([body], { type }),
86+
url: '/apps/snippets/snippet/download',
87+
method: 'GET',
88+
});
89+
}
90+
91+
it("recovers a refusal's reason from a blob response body", async () => {
92+
const recovered = await normalizeBlobError(
93+
blobFailure(
94+
403,
95+
JSON.stringify({ detail: "You don't have permission to perform this action" }),
96+
),
97+
);
98+
99+
expect(recovered.message).toBe("You don't have permission to perform this action");
100+
expect(recovered.status).toBe(403);
101+
expect(recovered.method).toBe('GET');
102+
});
103+
104+
it("keeps a 422's detail array reachable through parseFieldErrors", async () => {
105+
const recovered = await normalizeBlobError(
106+
blobFailure(
107+
422,
108+
JSON.stringify({ detail: [{ loc: ['body', 'name'], msg: 'field required' }] }),
109+
),
110+
);
111+
112+
expect(parseFieldErrors(recovered)).toEqual([{ path: 'name', message: 'field required' }]);
113+
});
114+
115+
it('leaves the error untouched when the body is not JSON', async () => {
116+
const original = blobFailure(502, '<html>Bad gateway</html>', 'text/html');
117+
118+
const recovered = await normalizeBlobError(original);
119+
120+
expect(recovered.message).toBe('HTTP 502');
121+
});
122+
123+
it('passes through an error with no blob body', async () => {
124+
const original = new ApiError({ kind: 'network', message: 'Network error' });
125+
126+
expect(await normalizeBlobError(original)).toBe(original);
127+
});
128+
});

frontend/packages/apps/dipper/src/DipperApp.test.tsx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ import {
2525
useDipperAppSchema,
2626
} from './hooks';
2727

28-
const { stopMutate, authMock } = vi.hoisted(() => ({
28+
const { stopMutate, stopState, authMock } = vi.hoisted(() => ({
2929
stopMutate: vi.fn(),
30+
stopState: { error: null as unknown },
3031
/** Flipped per test to cover the read-only (non-admin) rendering. */
3132
authMock: { canMutate: true },
3233
}));
@@ -82,10 +83,12 @@ vi.mock('@sep/framework', async () => {
8283
data,
8384
onViewLogs,
8485
onStopTask,
86+
actionError,
8587
}: {
8688
data: Array<{ id: number; status: string; task?: { name: string } }>;
8789
onViewLogs: (entry: unknown) => void;
8890
onStopTask?: (entry: { id: number }) => void;
91+
actionError?: unknown;
8992
}) => (
9093
<div>
9194
<span>history rows: {data.length}</span>
@@ -97,12 +100,24 @@ vi.mock('@sep/framework', async () => {
97100
Stop {String(data[0].id)}
98101
</button>
99102
) : null}
103+
{actionError ? (
104+
<div data-testid="task-history-action-error">
105+
{actionError instanceof Error ? actionError.message : String(actionError)}
106+
</div>
107+
) : null}
100108
</div>
101109
),
102110
TaskLogViewer: ({ taskHistoryId }: { taskHistoryId: number }) => (
103111
<div>logs for {taskHistoryId}</div>
104112
),
105-
useStopTaskHistory: () => ({ mutate: stopMutate, isPending: false }),
113+
useStopTaskHistory: () => ({
114+
mutate: stopMutate,
115+
isPending: false,
116+
error: stopState.error,
117+
reset: () => {
118+
stopState.error = null;
119+
},
120+
}),
106121
ReadOnlyNotice: ({ action, testId }: { action?: string; testId?: string }) => (
107122
<div data-testid={testId}>no permission to {action}</div>
108123
),
@@ -119,6 +134,7 @@ describe('DipperApp', () => {
119134

120135
beforeEach(() => {
121136
vi.clearAllMocks();
137+
stopState.error = null;
122138
authMock.canMutate = true;
123139
mockAppSchema.mockReturnValue({
124140
data: {
@@ -206,6 +222,67 @@ describe('DipperApp', () => {
206222
42,
207223
expect.objectContaining({ onSuccess: expect.any(Function) }),
208224
);
225+
expect(screen.queryByTestId('task-history-action-error')).not.toBeInTheDocument();
226+
});
227+
228+
it("reports a failed stop above the history with the server's own reason", () => {
229+
stopState.error = new Error("You don't have permission to perform this action");
230+
231+
render(<DipperApp />);
232+
233+
expect(screen.getByTestId('task-history-action-error')).toHaveTextContent(
234+
"You don't have permission to perform this action",
235+
);
236+
});
237+
});
238+
239+
describe('DipperApp — write access', () => {
240+
beforeEach(() => {
241+
vi.clearAllMocks();
242+
stopState.error = null;
243+
authMock.canMutate = true;
244+
mockAppSchema.mockReturnValue({
245+
data: { display_name: 'Dipper', description: 'Collect' },
246+
isLoading: false,
247+
error: null,
248+
} as unknown as ReturnType<typeof useDipperAppSchema>);
249+
mockFormSchema.mockReturnValue({
250+
data: { forms: [] },
251+
isLoading: false,
252+
error: null,
253+
} as unknown as ReturnType<typeof useDipperFormSchema>);
254+
mockHistory.mockReturnValue({
255+
data: { items: [] },
256+
isLoading: false,
257+
error: null,
258+
refetch: vi.fn(),
259+
} as unknown as ReturnType<typeof useDipperHistory>);
260+
mockExecution.mockReturnValue({
261+
mutate: vi.fn(),
262+
isPending: false,
263+
isError: false,
264+
error: null,
265+
} as unknown as ReturnType<typeof useDipperExecution>);
266+
});
267+
268+
it('renders the execute form for a session that may mutate', () => {
269+
render(<DipperApp />);
270+
271+
fireEvent.click(screen.getByRole('button', { name: 'Select service' }));
272+
273+
expect(screen.getByRole('button', { name: 'Execute' })).toBeInTheDocument();
274+
expect(screen.queryByTestId('dipper-execute-read-only')).not.toBeInTheDocument();
275+
});
276+
277+
it('renders no execute form for a non-admin, keeping the history readable', () => {
278+
authMock.canMutate = false;
279+
render(<DipperApp />);
280+
281+
fireEvent.click(screen.getByRole('button', { name: 'Select service' }));
282+
283+
expect(screen.getByTestId('dipper-execute-read-only')).toBeInTheDocument();
284+
expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument();
285+
expect(screen.getByText('Execution history')).toBeInTheDocument();
209286
});
210287
});
211288

frontend/packages/apps/dipper/src/DipperApp.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ export function DipperApp() {
235235
}
236236
}}
237237
isStopping={stop.isPending}
238+
actionError={stop.error}
239+
onDismissActionError={stop.reset}
238240
/>
239241
)}
240242

frontend/packages/apps/inventory/src/ConnectivityControl.test.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,34 @@ describe('ConnectivityControl', () => {
144144
await screen.findByText(/missing node or port information/i);
145145
});
146146

147-
it('shows a generic snackbar when the request never reaches the server', async () => {
147+
it('reports in-tree when the request never reaches the server', async () => {
148148
const user = userEvent.setup();
149149
vi.spyOn(apiClient, 'post').mockRejectedValue(new Error('network down'));
150150
renderControl('mysql');
151151
await user.click(await screen.findByRole('button', { name: CHECK_BUTTON }));
152-
await screen.findByText(/could not be started/i);
152+
expect(await screen.findByTestId('connectivity-action-error')).toHaveTextContent(
153+
/network down/i,
154+
);
155+
});
156+
157+
it("reports a refusal in-tree with the server's own reason", async () => {
158+
const user = userEvent.setup();
159+
await stubPostError(403, "You don't have permission to perform this action");
160+
renderControl('mysql');
161+
await user.click(await screen.findByRole('button', { name: CHECK_BUTTON }));
162+
expect(await screen.findByTestId('connectivity-action-error')).toHaveTextContent(
163+
"You don't have permission to perform this action",
164+
);
165+
});
166+
167+
it('reports nothing when the probe connects', async () => {
168+
const user = userEvent.setup();
169+
stubPostResult({ success: true });
170+
renderControl('mysql');
171+
await user.click(await screen.findByRole('button', { name: CHECK_BUTTON }));
172+
await screen.findByText(/connectivity check passed/i);
173+
expect(screen.queryByTestId('connectivity-action-error')).not.toBeInTheDocument();
174+
expect(screen.queryByTestId('connectivity-probe-failure')).not.toBeInTheDocument();
153175
});
154176

155177
it('re-enables the button after a failed check so it can be retried', async () => {

frontend/packages/apps/inventory/src/ConnectivityControl.tsx

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
*/
1717

18-
import { Box, Button, CircularProgress, Tooltip } from '@mui/material';
18+
import { useState } from 'react';
19+
import { Alert, Box, Button, CircularProgress, Tooltip } from '@mui/material';
1920
import NetworkCheckIcon from '@mui/icons-material/NetworkCheck';
20-
import { ApiError, useAuth } from '@sep/api';
21+
import { useAuth } from '@sep/api';
22+
import { ActionErrorAlert, useActionError } from '@sep/framework';
2123
import { useSnackbar } from 'notistack';
2224
import { useCheckServiceConnectivity } from './hooks';
2325

@@ -45,29 +47,29 @@ export function ConnectivityControl({
4547
const { canMutate } = useAuth();
4648
const checkConnectivity = useCheckServiceConnectivity(serviceId);
4749
const { enqueueSnackbar } = useSnackbar();
50+
const checkError = useActionError();
51+
const [probeFailure, setProbeFailure] = useState<string | null>(null);
4852

4953
const isConnectable =
5054
typeof serviceType === 'string' && CONNECTABLE_SERVICE_TYPES.has(serviceType);
5155
const isPending = checkConnectivity.isPending;
5256

5357
function handleCheck() {
58+
setProbeFailure(null);
59+
checkError.clearError();
5460
checkConnectivity.mutate(undefined, {
5561
onSuccess: (result) => {
5662
if (result.success) {
5763
enqueueSnackbar('Connectivity check passed', { variant: 'success' });
5864
} else {
59-
enqueueSnackbar(`Connectivity check failed: ${result.error ?? 'Unknown error'}`, {
60-
variant: 'error',
61-
});
65+
// A reachability failure is a successful request reporting a bad
66+
// result, so it renders here rather than through the error alert.
67+
setProbeFailure(result.error ?? 'Unknown error');
6268
}
6369
},
64-
onError: (err) => {
65-
const message =
66-
err instanceof ApiError
67-
? err.message
68-
: 'Connectivity check could not be started. Please try again.';
69-
enqueueSnackbar(message, { variant: 'error' });
70-
},
70+
// The request itself failing (refused, 5xx, network) reports in-tree and
71+
// nowhere else: one signal, and one that does not need a host snackbar.
72+
onError: (err) => checkError.reportError(err),
7173
});
7274
}
7375

@@ -96,6 +98,25 @@ export function ConnectivityControl({
9698
</Button>
9799
</Box>
98100
</Tooltip>
101+
102+
<ActionErrorAlert
103+
error={checkError.error}
104+
onClose={checkError.clearError}
105+
fallback="Connectivity check could not be started. Please try again."
106+
sx={{ mt: 2 }}
107+
testId="connectivity-action-error"
108+
/>
109+
110+
{probeFailure !== null && (
111+
<Alert
112+
severity="error"
113+
onClose={() => setProbeFailure(null)}
114+
sx={{ mt: 2 }}
115+
data-testid="connectivity-probe-failure"
116+
>
117+
Connectivity check failed: {probeFailure}
118+
</Alert>
119+
)}
99120
</Box>
100121
);
101122
}

0 commit comments

Comments
 (0)