Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4f356b2
PMM-15293 Add a token-minter seam to the SEP API client
nachodd Aug 5, 2026
3ef1724
PMM-15293 Mint the SEP bearer from the PMM session
nachodd Aug 5, 2026
a275f7a
PMM-15293 Clone only replay-eligible requests
nachodd Aug 6, 2026
a2c6df1
PMM-15293 Fail closed without discarding user work
nachodd Aug 6, 2026
91970a9
Merge branch 'PMM-15216' into PMM-15293-sep-session-exchange
nachodd Aug 7, 2026
c871d50
PMM-15293 Merge PMM-15216, re-pathing onto the /sep prefix
nachodd Aug 10, 2026
c09da4b
Merge branch 'main' into PMM-15293-sep-session-exchange
nachodd Aug 10, 2026
ec3d246
PMM-15293 Let the dev proxy strip the SEP prefix
nachodd Aug 10, 2026
1e5aa9d
PMM-15294 Submit ServiceNow inputs to SEP settings
nachodd Aug 10, 2026
0443024
PMM-15294 Judge a secretless plan on the override
nachodd Aug 10, 2026
439dfba
PMM-15293 Point the strip flag at SEP__ROOT_PATH
nachodd Aug 11, 2026
55a127a
Merge remote-tracking branch 'origin/PMM-15216' into PMM-15293-sep-se…
nachodd Aug 12, 2026
1764e74
Merge remote-tracking branch 'origin/PMM-15293-sep-session-exchange' …
nachodd Aug 12, 2026
018ddab
PMM-15294 Point ServiceNow form at the renamed peak-ui package
nachodd Aug 12, 2026
44745d5
PMM-15293 Drop the SSR-era HTML and 303 handling again
yyyyyyyan Aug 12, 2026
f98382c
Merge remote-tracking branch 'origin/PMM-15293-sep-session-exchange' …
yyyyyyyan Aug 12, 2026
6e4f9a3
PMM-15294 Extract Percona Support URL to a constant
nachodd Aug 13, 2026
59ff70a
PMM-15337 Extract the ServiceNow connection hook
nachodd Aug 13, 2026
4077a54
PMM-15337 Gate diagnostics on ServiceNow setup
nachodd Aug 13, 2026
d603cde
PMM-15337 Rename nav entry and swap its icon
nachodd Aug 13, 2026
66c9321
PMM-15337 Guard the New incident button
nachodd Aug 13, 2026
52b4f1d
PMM-15337 Fail open when SEP lacks the delivery key
nachodd Aug 13, 2026
fb2cc99
Merge remote-tracking branch 'origin/PMM-15216' into PMM-15293-sep-se…
nachodd Aug 17, 2026
d8e9e9f
Merge branch 'PMM-15293-sep-session-exchange' into PMM-15294-sep-diag…
nachodd Aug 17, 2026
8c03601
Merge branch 'PMM-15294-sep-diagnostics-settings' into PMM-15337-supp…
nachodd Aug 17, 2026
f7310ce
PMM-15293 Drop the invented platform name from SEP errors
nachodd Aug 19, 2026
58cc3ee
Merge branch 'PMM-15293-sep-session-exchange' into PMM-15294-sep-diag…
nachodd Aug 19, 2026
1efa5af
Merge branch 'PMM-15294-sep-diagnostics-settings' into PMM-15337-supp…
nachodd Aug 19, 2026
ead614c
PMM-15358 Hide SEP write controls from non-admins
nachodd Aug 22, 2026
c28d28b
PMM-15359 Report failed SEP UI actions in-tree
nachodd Aug 22, 2026
3c5022b
Merge branch 'PMM-15216' into PMM-15337-support-diagnostics-setup-gate
nachodd Aug 24, 2026
5c875e2
Merge branch 'PMM-15337-support-diagnostics-setup-gate' into PMM-1535…
nachodd Aug 25, 2026
5aed1b2
PMM-15358 Open SEP routes to non-admin sessions
nachodd Aug 25, 2026
b981263
Merge branch 'PMM-15358-hide-write-controls-non-admin' into PMM-15359…
nachodd Aug 25, 2026
2c11394
Merge remote-tracking branch 'origin/PMM-15216' into PMM-15358-hide-w…
nachodd Aug 25, 2026
cc8fa09
Merge branch 'PMM-15358-hide-write-controls-non-admin' into PMM-15359…
nachodd Aug 25, 2026
6241e4c
PMM-15358 Address PR review comments
nachodd Aug 25, 2026
5dd0b60
Merge branch 'PMM-15358-hide-write-controls-non-admin' into PMM-15359…
nachodd Aug 25, 2026
e9407d5
PMM-15359 Address PR review comments
nachodd Aug 25, 2026
77eb65a
PMM-15359 Make the stop-failure contract a type error
nachodd Aug 25, 2026
7abbc41
Merge branch 'PMM-15216' into PMM-15359-report-failed-ui-actions
fabio-silva Aug 27, 2026
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
46 changes: 46 additions & 0 deletions ui/packages/sep/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,52 @@ export function parseFieldErrors(error: unknown): FieldValidationError[] {
return result;
}

/**
* Recover the server's reason from a request made with `responseType: 'blob'`.
*
* Axios hands back the error body in the response type it was asked for, so a
* download's 403 arrives as a `Blob` rather than parsed JSON and
* `messageFromPayload` cannot see its `detail` — leaving the synthesized
* `HTTP 403`. Read the blob, parse it, and return an `ApiError` carrying the
* reason (and the parsed payload, so a 422's `detail` array is still reachable
* through {@link parseFieldErrors}).
*
* Async by necessity — `Blob.text()` is a promise — so callers await it in
* their own catch rather than getting it from an interceptor.
*
* Falls back to the unmodified error whenever the body is not a readable JSON
* blob: an HTML error page, an opaque binary body, or a network failure with no
* response at all.
*/
export async function normalizeBlobError(error: unknown): Promise<ApiError> {
const apiError = normalizeAxiosError(error);
const body = apiError.data;
if (typeof Blob === 'undefined' || !(body instanceof Blob)) {
return apiError;
}

let payload: unknown;
try {
payload = JSON.parse(await body.text());
} catch {
return apiError;
}

const message = messageFromPayload(payload, apiError.message);
return new ApiError(
{
kind: apiError.kind,
status: apiError.status,
code: apiError.code,
message,
url: apiError.url,
method: apiError.method,
data: payload,
},
apiError.original
);
}

export function normalizeAxiosError(error: unknown): ApiError {
if (error instanceof ApiError) {
return error;
Expand Down
7 changes: 6 additions & 1 deletion ui/packages/sep/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ export {
export type { AuthSession, AuthState } from './auth-context';

// Errors
export { ApiError, normalizeAxiosError, parseFieldErrors } from './errors';
export {
ApiError,
normalizeAxiosError,
normalizeBlobError,
parseFieldErrors,
} from './errors';
export type {
ApiErrorDetails,
ApiErrorKind,
Expand Down
68 changes: 67 additions & 1 deletion ui/packages/sep/api/tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

import { describe, expect, it } from 'vitest';
import { ApiError, parseFieldErrors } from '../src/errors';
import { ApiError, normalizeBlobError, parseFieldErrors } from '../src/errors';

function http422(detail: unknown): ApiError {
return new ApiError({
Expand Down Expand Up @@ -105,3 +105,69 @@ describe('parseFieldErrors', () => {
expect(parseFieldErrors('boom')).toEqual([]);
});
});

describe('normalizeBlobError', () => {
function blobFailure(
status: number,
body: string,
type = 'application/json'
): ApiError {
return new ApiError({
kind: 'http',
status,
message: `HTTP ${status}`,
data: new Blob([body], { type }),
url: '/files/7/download',
method: 'GET',
});
}

it("recovers a refusal's reason from a blob response body", async () => {
const recovered = await normalizeBlobError(
blobFailure(
403,
JSON.stringify({
detail: "You don't have permission to perform this action",
})
)
);

expect(recovered.message).toBe(
"You don't have permission to perform this action"
);
expect(recovered.status).toBe(403);
expect(recovered.method).toBe('GET');
});

it("keeps a 422's detail array reachable through parseFieldErrors", async () => {
const recovered = await normalizeBlobError(
blobFailure(
422,
JSON.stringify({
detail: [{ loc: ['body', 'name'], msg: 'field required' }],
})
)
);

expect(parseFieldErrors(recovered)).toEqual([
{ path: 'name', message: 'field required' },
]);
});

it('leaves the error untouched when the body is not JSON', async () => {
const original = blobFailure(502, '<html>Bad gateway</html>', 'text/html');

const recovered = await normalizeBlobError(original);

expect(recovered.message).toBe('HTTP 502');
});

it('passes through an error with no blob body', async () => {
const original = new ApiError({
kind: 'network',
message: 'Network error',
});

expect(await normalizeBlobError(original)).toBe(original);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (C) 2026 Percona LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { act, render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ApiError } from '@sep/api';
import { ActionErrorAlert } from './ActionErrorAlert';
import {
actionErrorMessage,
DEFAULT_ACTION_ERROR_FALLBACK,
} from './actionErrorMessage';
import { useActionError } from './useActionError';

function http(status: number, message: string, data?: unknown): ApiError {
return new ApiError({ kind: 'http', status, message, data });
}

describe('actionErrorMessage', () => {
it("uses the refusal's own reason for a 403", () => {
expect(
actionErrorMessage(
http(403, "You don't have permission to perform this action")
)
).toBe("You don't have permission to perform this action");
});

it('reads the per-field detail array of a 422 rather than the HTTP 422 fallback', () => {
const error = http(422, 'HTTP 422', {
detail: [
{ loc: ['body', 'limit'], msg: 'ensure this value is greater than 0' },
{ loc: ['body', 'task_name'], msg: 'field required' },
],
});

expect(actionErrorMessage(error)).toBe(
'limit: ensure this value is greater than 0; task_name: field required'
);
});

it('ignores an array detail on any status other than 422', () => {
// A batch endpoint listing per-item failures still has a usable message of
// its own; only a 422 means the array is the per-field validation shape.
const error = http(400, 'Batch approval failed for 2 snippets', {
detail: [{ loc: ['check.sh'], msg: 'missing on disk' }],
});

expect(actionErrorMessage(error)).toBe(
'Batch approval failed for 2 snippets'
);
});

it('reports transport failures with their own message', () => {
expect(
actionErrorMessage(
new ApiError({ kind: 'network', message: 'Network error' })
)
).toBe('Network error');
expect(
actionErrorMessage(
new ApiError({ kind: 'timeout', message: 'Request timed out' })
)
).toBe('Request timed out');
});

it('falls back only when nothing carries a message', () => {
expect(actionErrorMessage(new Error(''))).toBe(
DEFAULT_ACTION_ERROR_FALLBACK
);
expect(actionErrorMessage({}, 'Delete failed')).toBe('Delete failed');
expect(actionErrorMessage(null)).toBe(DEFAULT_ACTION_ERROR_FALLBACK);
});
});

describe('ActionErrorAlert', () => {
it('renders nothing without an error', () => {
const { container } = render(<ActionErrorAlert error={null} />);
expect(container).toBeEmptyDOMElement();
});

it("renders the server's reason", () => {
render(<ActionErrorAlert error={http(409, 'Sync already running')} />);
expect(screen.getByTestId('action-error-alert')).toHaveTextContent(
'Sync already running'
);
});

it('offers dismissal when a handler is given', async () => {
const onClose = vi.fn();
render(<ActionErrorAlert error={new Error('boom')} onClose={onClose} />);

await userEvent.click(screen.getByRole('button', { name: /close/i }));

expect(onClose).toHaveBeenCalled();
});
});

describe('useActionError', () => {
it('holds a reported failure until it is cleared', () => {
const { result } = renderHook(() => useActionError());
expect(result.current.message).toBeNull();

act(() => result.current.reportError(http(403, 'Refused')));
expect(result.current.message).toBe('Refused');

act(() => result.current.clearError());
expect(result.current.message).toBeNull();
});

it('never reports a failure as no failure', () => {
const { result } = renderHook(() => useActionError('Delete failed'));

act(() => result.current.reportError(null));

expect(result.current.message).toBe('Delete failed');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright (C) 2026 Percona LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import Alert from '@mui/material/Alert';
import type { SxProps, Theme } from '@mui/material/styles';
import { actionErrorMessage } from './actionErrorMessage';

export interface ActionErrorAlertProps {
/** Raw failure — a mutation's `error`, or the value held by `useActionError`. */
error: unknown;
/** Shown only when the error carries no message of its own. */
fallback?: string;
/** Renders the dismiss button when given (e.g. `mutation.reset`). */
onClose?: () => void;
sx?: SxProps<Theme>;
testId?: string;
}

/**
* Report a failed action from SEP's own component tree.
*
* Every mutation in SEP can be refused — the API restricts state-changing
* routes to admins — so a failure that is only enqueued as a toast is invisible
* wherever the host application mounts no snackbar provider. This alert renders
* in the failing component's own tree, so it does not depend on that host
* contract, and it carries the server's reason rather than a generic sentence.
*
* Renders nothing when there is no error, so it can sit unconditionally in a
* layout.
*/
export function ActionErrorAlert({
error,
fallback,
onClose,
sx,
testId = 'action-error-alert',
}: ActionErrorAlertProps) {
if (error === null || error === undefined) {
return null;
}

return (
<Alert severity="error" onClose={onClose} sx={sx} data-testid={testId}>
{actionErrorMessage(error, fallback)}
</Alert>
);
}
Loading
Loading