diff --git a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md
index 441cad1f8..b9cc18f95 100644
--- a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md
+++ b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md
@@ -117,6 +117,11 @@ Phase-1 rule:
an edit overlay payload without requiring masked-secret re-entry
4. exhausting the implemented VI JSON release probe floor should classify as
`unsupported_version`, not as a generic endpoint error
+5. connection-test failures must preserve canonical backend `code` plus
+ string-valued `details` such as `details.category` and `details.error`
+ through the shared browser client so the settings workflow can distinguish
+ version-floor, auth, TLS, permission, and network failures without a
+ VMware-only fetch path
## Runtime Health And Poll Summary Contract
diff --git a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md
index 935fafa06..c9cf5fba4 100644
--- a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md
+++ b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md
@@ -79,6 +79,9 @@ Those routes should follow the same shared semantics already used by TrueNAS:
4. optional edited-form overlay payload on saved-connection test
5. list responses reloading refreshed last-success or last-error state after
a saved-connection test
+6. draft-test failures keeping canonical backend `code` plus string-valued
+ `details.category` / `details.error` through the shared browser client so
+ onboarding can show phase-1 failure guidance without a VMware-only transport
## Expected List Response Shape
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 7853290d3..662717164 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -723,6 +723,14 @@ remains the saved connection retest surface. The explicit disabled path also
stays on this boundary: `404 vmware_disabled` means the operator or runtime has
opted out of the default-on VMware candidate, not that the platform requires a
different onboarding contract.
+That same VMware test contract now also owns structured setup-failure
+classification. When `POST /api/vmware/connections/test` or
+`POST /api/vmware/connections/{id}/test` fails, the backend payload must
+preserve the canonical top-level `code` plus string-valued `details.error` and
+`details.category`, and shared browser normalization in
+`frontend-modern/src/utils/apiClient.ts` plus
+`frontend-modern/src/api/responseUtils.ts` must carry that metadata through the
+shared error object without inventing a VMware-only fetch or parsing path.
That same VMware API boundary now also owns the phase-1 runtime negative
space around inventory projection. `internal/api/router.go` may wire VMware's
supplemental ingest into the shared `/api/resources` surface so canonical
diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index 2d4a32328..b795684fc 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -763,6 +763,14 @@ for hosted tenant requests. Cloud and MSP surfaces may show failures from
`frontend-modern/src/utils/apiClient.ts`, but they must resolve canonical JSON
`error` / `message` fields before showing UI feedback instead of leaking raw
response payloads while tenant-scoped org headers are still in flight.
+That same boundary now also owns stable structured error metadata on the shared
+browser client. When backend routes return canonical JSON `code` plus
+string-valued `details`, `frontend-modern/src/utils/apiClient.ts` must preserve
+that metadata on the thrown error object instead of collapsing everything to one
+display string. Hosted and platform-onboarding surfaces may then classify
+supported failure modes from the shared error object, but they must not invent
+route-local parsing rules or provider-local transport shims to recover metadata
+the shared client dropped.
Hosted tenant runtime env is part of the same contract too: provisioned
containers must carry hosted-safe tenant context such as
`PULSE_TENANT_ID=`, `PULSE_MULTI_TENANT_ENABLED=true`, and an explicit
diff --git a/frontend-modern/src/api/__tests__/responseUtils.test.ts b/frontend-modern/src/api/__tests__/responseUtils.test.ts
index 20bc4512c..76bc23903 100644
--- a/frontend-modern/src/api/__tests__/responseUtils.test.ts
+++ b/frontend-modern/src/api/__tests__/responseUtils.test.ts
@@ -1,639 +1,20 @@
-import { describe, expect, it, vi } from 'vitest';
-import {
- assertAPIResponseOK,
- assertAPIResponseOKOrAllowedStatus,
- assertAPIResponseOKOrThrowStatus,
- arrayOrUndefined,
- arrayOrEmpty,
- apiErrorStatus,
- apiResponseStatus,
- coerceTimestampMillis,
- finiteNumberOrUndefined,
- isAPIErrorStatus,
- isAPIResponseStatus,
- normalizeStructuredAPIError,
- objectArrayFieldOrEmpty,
- parseJSONSafe,
- parseJSONTextSafe,
- parseOptionalAPIResponse,
- parseOptionalAPIResponseOrAllowedStatus,
- parseOptionalAPIResponseOrNull,
- parseOptionalSuccessAPIResponse,
- parseOptionalJSON,
- parseRequiredAPIResponse,
- parseRequiredAPIResponseOrNull,
- parseRequiredJSON,
- promoteLegacyAlertIdentifier,
- readAPIErrorMessage,
- optionalTrimmedString,
- strictBoolean,
- strictString,
- stringRecordOrUndefined,
- stringArray,
- trimmedString,
- withAPIErrorStatusFallback,
- withAPIErrorStatusNull,
-} from '@/api/responseUtils';
+import { describe, expect, it } from 'vitest';
+import { apiErrorCode, apiErrorDetailField } from '@/api/responseUtils';
-describe('readAPIErrorMessage', () => {
- it('returns fallback for empty response', async () => {
- const response = new Response('', { status: 500 });
- const result = await readAPIErrorMessage(response, 'Fallback error');
- expect(result).toBe('Fallback error');
- });
-
- it('returns fallback when response throws', async () => {
- const response = {
- text: vi.fn().mockRejectedValue(new Error('Network error')),
- } as unknown as Response;
- const result = await readAPIErrorMessage(response, 'Fallback error');
- expect(result).toBe('Fallback error');
- });
-
- it('extracts error field from JSON', async () => {
- const response = new Response(JSON.stringify({ error: 'Something went wrong' }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('Something went wrong');
- });
-
- it('extracts nested error.message from JSON', async () => {
- const response = new Response(JSON.stringify({ error: { message: 'Nested error' } }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('Nested error');
- });
-
- it('extracts message field from JSON', async () => {
- const response = new Response(JSON.stringify({ message: 'API message' }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('API message');
- });
-
- it('prefers error over message field', async () => {
- const response = new Response(JSON.stringify({ error: 'Error text', message: 'Message text' }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('Error text');
- });
-
- it('returns raw text when not valid JSON', async () => {
- const response = new Response('Not a JSON string');
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('Not a JSON string');
- });
-
- it('trims whitespace from error message', async () => {
- const response = new Response(JSON.stringify({ error: ' Trimmed error ' }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('Trimmed error');
- });
-
- it('returns raw JSON when error is not a string', async () => {
- const response = new Response(JSON.stringify({ error: { code: 500 } }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('{"error":{"code":500}}');
- });
-
- it('returns raw JSON when message is not a string', async () => {
- const response = new Response(JSON.stringify({ message: { text: 'Nested' } }));
- const result = await readAPIErrorMessage(response, 'Fallback');
- expect(result).toBe('{"message":{"text":"Nested"}}');
- });
-});
-
-describe('assertAPIResponseOK', () => {
- it('does nothing for ok responses', async () => {
- await expect(assertAPIResponseOK(new Response('', { status: 200 }), 'Fallback')).resolves.toBe(
- undefined,
- );
- });
-
- it('throws the canonical parsed error message for non-ok responses', async () => {
- await expect(
- assertAPIResponseOK(new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }), 'Fallback'),
- ).rejects.toThrow('Bad request');
- });
-});
-
-describe('assertAPIResponseOKOrAllowedStatus', () => {
- it('returns for ok responses and allowed statuses', async () => {
- await expect(
- assertAPIResponseOKOrAllowedStatus(new Response(null, { status: 204 }), 204, 'Fallback'),
- ).resolves.toBeUndefined();
- await expect(
- assertAPIResponseOKOrAllowedStatus(new Response('', { status: 404 }), [204, 404], 'Fallback'),
- ).resolves.toBeUndefined();
- });
-
- it('throws the canonical parsed error for disallowed non-ok responses', async () => {
- await expect(
- assertAPIResponseOKOrAllowedStatus(
- new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }),
- 404,
- 'Fallback',
- ),
- ).rejects.toThrow('Bad request');
- });
-});
-
-describe('assertAPIResponseOKOrThrowStatus', () => {
- it('throws the explicit message for the configured status', async () => {
- await expect(
- assertAPIResponseOKOrThrowStatus(
- new Response('', { status: 404 }),
- 404,
- 'Custom missing resource message',
- 'Fallback',
- ),
- ).rejects.toThrow('Custom missing resource message');
- });
-
- it('falls back to the canonical parsed error for other non-ok responses', async () => {
- await expect(
- assertAPIResponseOKOrThrowStatus(
- new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }),
- 404,
- 'Custom missing resource message',
- 'Fallback',
- ),
- ).rejects.toThrow('Bad request');
- });
-});
-
-describe('parseRequiredAPIResponse', () => {
- it('asserts ok and parses valid JSON payloads', async () => {
- const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
- await expect(
- parseRequiredAPIResponse<{ ok: boolean }>(response, 'Request failed', 'Parse failed'),
- ).resolves.toEqual({ ok: true });
- });
-
- it('throws the request error when the response is not ok', async () => {
- const response = new Response(JSON.stringify({ error: 'Request failed' }), { status: 500 });
- await expect(
- parseRequiredAPIResponse(response, 'Request failed', 'Parse failed'),
- ).rejects.toThrow('Request failed');
- });
-});
-
-describe('parseOptionalAPIResponse', () => {
- it('asserts ok and returns emptyValue for empty payloads', async () => {
- const response = new Response('', { status: 200 });
- await expect(
- parseOptionalAPIResponse(response, [], 'Request failed', 'Parse failed'),
- ).resolves.toEqual([]);
- });
-
- it('throws the request error when the response is not ok', async () => {
- const response = new Response(JSON.stringify({ error: 'Request failed' }), { status: 500 });
- await expect(
- parseOptionalAPIResponse(response, [], 'Request failed', 'Parse failed'),
- ).rejects.toThrow('Request failed');
- });
-});
-
-describe('parseOptionalSuccessAPIResponse', () => {
- it('returns success true for empty successful responses', async () => {
- await expect(
- parseOptionalSuccessAPIResponse(new Response('', { status: 200 }), 'Request failed', 'Parse failed'),
- ).resolves.toEqual({ success: true });
- });
-
- it('parses valid payloads when present', async () => {
- await expect(
- parseOptionalSuccessAPIResponse<{ success?: boolean; commandId?: string }>(
- new Response(JSON.stringify({ success: true, commandId: 'cmd-1' }), { status: 200 }),
- 'Request failed',
- 'Parse failed',
- ),
- ).resolves.toEqual({ success: true, commandId: 'cmd-1' });
- });
-});
-
-describe('parseOptionalAPIResponseOrAllowedStatus', () => {
- it('returns the empty value for allowed statuses', async () => {
- await expect(
- parseOptionalAPIResponseOrAllowedStatus(
- new Response(null, { status: 204 }),
- [204, 404],
- {},
- 'Request failed',
- 'Parse failed',
- ),
- ).resolves.toEqual({});
- await expect(
- parseOptionalAPIResponseOrAllowedStatus(new Response('', { status: 404 }), [204, 404], {}, 'Request failed', 'Parse failed'),
- ).resolves.toEqual({});
- });
-
- it('parses valid JSON payloads for non-allowed statuses', async () => {
- await expect(
- parseOptionalAPIResponseOrAllowedStatus(
- new Response(JSON.stringify({ ok: true }), { status: 200 }),
- [204, 404],
- {},
- 'Request failed',
- 'Parse failed',
- ),
- ).resolves.toEqual({ ok: true });
- });
-});
-
-describe('parseRequiredAPIResponseOrNull', () => {
- it('returns null when the response matches the null status', async () => {
- const response = new Response('', { status: 404 });
- await expect(
- parseRequiredAPIResponseOrNull(response, 404, 'Request failed', 'Parse failed'),
- ).resolves.toBeNull();
- });
-
- it('parses valid JSON payloads for non-null statuses', async () => {
- const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
- await expect(
- parseRequiredAPIResponseOrNull<{ ok: boolean }>(
- response,
- 404,
- 'Request failed',
- 'Parse failed',
- ),
- ).resolves.toEqual({ ok: true });
- });
-});
-
-describe('parseOptionalAPIResponseOrNull', () => {
- it('returns null when the response matches the null status', async () => {
- const response = new Response('', { status: 404 });
- await expect(
- parseOptionalAPIResponseOrNull(response, 404, 'Request failed', 'Parse failed'),
- ).resolves.toBeNull();
- });
-
- it('parses valid JSON payloads for non-null statuses', async () => {
- const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
- await expect(
- parseOptionalAPIResponseOrNull<{ ok: boolean }>(
- response,
- 404,
- 'Request failed',
- 'Parse failed',
- ),
- ).resolves.toEqual({ ok: true });
- });
-});
-
-describe('parseOptionalJSON', () => {
- it('returns emptyValue for empty response', async () => {
- const response = new Response('');
- const result = await parseOptionalJSON(response, [], 'Parse error');
- expect(result).toEqual([]);
- });
-
- it('returns emptyValue for whitespace-only response', async () => {
- const response = new Response(' ');
- const result = await parseOptionalJSON(response, null, 'Parse error');
- expect(result).toBeNull();
- });
-
- it('parses valid JSON', async () => {
- const response = new Response(JSON.stringify({ name: 'test', count: 5 }));
- const result = await parseOptionalJSON(response, {}, 'Parse error');
- expect(result).toEqual({ name: 'test', count: 5 });
- });
-
- it('parses JSON array', async () => {
- const response = new Response(JSON.stringify([1, 2, 3]));
- const result = await parseOptionalJSON(response, [], 'Parse error');
- expect(result).toEqual([1, 2, 3]);
- });
-
- it('throws error for invalid JSON', async () => {
- const response = new Response('not valid json');
- await expect(parseOptionalJSON(response, {}, 'Custom parse error')).rejects.toThrow(
- 'Custom parse error',
- );
- });
-});
-
-describe('parseRequiredJSON', () => {
- it('parses valid JSON payloads', async () => {
- const response = new Response(JSON.stringify({ name: 'test', count: 5 }));
- const result = await parseRequiredJSON(response, 'Parse error');
- expect(result).toEqual({ name: 'test', count: 5 });
- });
-
- it('throws for empty response bodies', async () => {
- const response = new Response('');
- await expect(parseRequiredJSON(response, 'Required parse error')).rejects.toThrow(
- 'Required parse error',
- );
- });
-
- it('throws for invalid JSON payloads', async () => {
- const response = new Response('not valid json');
- await expect(parseRequiredJSON(response, 'Required parse error')).rejects.toThrow(
- 'Required parse error',
- );
- });
-});
-
-describe('parseJSONSafe', () => {
- it('parses valid JSON payloads', async () => {
- const response = new Response(JSON.stringify({ ok: true }));
- const result = await parseJSONSafe<{ ok: boolean }>(response);
- expect(result).toEqual({ ok: true });
- });
-
- it('returns null for empty payloads', async () => {
- const response = new Response('');
- const result = await parseJSONSafe(response);
- expect(result).toBeNull();
- });
-
- it('returns null for invalid JSON payloads', async () => {
- const response = new Response('not valid json');
- const result = await parseJSONSafe(response);
- expect(result).toBeNull();
- });
-});
-
-describe('parseJSONTextSafe', () => {
- it('parses valid JSON text', () => {
- expect(parseJSONTextSafe<{ ok: boolean }>('{"ok":true}')).toEqual({ ok: true });
- });
-
- it('returns null for empty text', () => {
- expect(parseJSONTextSafe(' ')).toBeNull();
- });
-
- it('returns null for invalid JSON text', () => {
- expect(parseJSONTextSafe('not valid json')).toBeNull();
- });
-});
-
-describe('arrayOrEmpty', () => {
- it('returns arrays unchanged', () => {
- expect(arrayOrEmpty(['a', 'b'])).toEqual(['a', 'b']);
- });
-
- it('returns empty array for non-array values', () => {
- expect(arrayOrEmpty(null)).toEqual([]);
- expect(arrayOrEmpty({ items: ['a'] })).toEqual([]);
- });
-});
-
-describe('arrayOrUndefined', () => {
- it('returns arrays unchanged', () => {
- expect(arrayOrUndefined(['a', 'b'])).toEqual(['a', 'b']);
- });
-
- it('returns undefined for non-array values', () => {
- expect(arrayOrUndefined(null)).toBeUndefined();
- expect(arrayOrUndefined({ items: ['a'] })).toBeUndefined();
- });
-});
-
-describe('objectArrayFieldOrEmpty', () => {
- it('returns object array fields unchanged', () => {
- expect(objectArrayFieldOrEmpty({ items: ['a', 'b'] }, 'items')).toEqual(['a', 'b']);
- });
-
- it('returns empty array for missing or invalid object fields', () => {
- expect(objectArrayFieldOrEmpty(null, 'items')).toEqual([]);
- expect(objectArrayFieldOrEmpty({ items: 'bad' }, 'items')).toEqual([]);
- });
-});
-
-describe('trimmedString', () => {
- it('trims string input and coerces non-null values', () => {
- expect(trimmedString(' value ')).toBe('value');
- expect(trimmedString(42)).toBe('42');
- expect(trimmedString(null)).toBe('');
- });
-});
-
-describe('optionalTrimmedString', () => {
- it('returns undefined for empty normalized strings', () => {
- expect(optionalTrimmedString(' ')).toBeUndefined();
- expect(optionalTrimmedString(null)).toBeUndefined();
- });
-
- it('returns normalized string when present', () => {
- expect(optionalTrimmedString(' value ')).toBe('value');
- });
-});
-
-describe('strictString', () => {
- it('returns strings unchanged and falls back for non-strings', () => {
- expect(strictString('value')).toBe('value');
- expect(strictString(42)).toBe('');
- expect(strictString(42, 'fallback')).toBe('fallback');
- });
-});
-
-describe('strictBoolean', () => {
- it('returns booleans unchanged and falls back for non-booleans', () => {
- expect(strictBoolean(true)).toBe(true);
- expect(strictBoolean(false)).toBe(false);
- expect(strictBoolean('true')).toBe(false);
- expect(strictBoolean('true', true)).toBe(true);
- });
-});
-
-describe('finiteNumberOrUndefined', () => {
- it('returns finite numbers and rejects invalid values', () => {
- expect(finiteNumberOrUndefined(0)).toBe(0);
- expect(finiteNumberOrUndefined(1.5)).toBe(1.5);
- expect(finiteNumberOrUndefined('1')).toBeUndefined();
- expect(finiteNumberOrUndefined(Number.NaN)).toBeUndefined();
- });
-});
-
-describe('coerceTimestampMillis', () => {
- it('keeps finite numbers and parses valid timestamp strings', () => {
- expect(coerceTimestampMillis(1234, 9999)).toBe(1234);
- expect(coerceTimestampMillis(' 2026-01-01T00:00:00Z ', 9999)).toBe(
- Date.parse('2026-01-01T00:00:00Z'),
- );
- });
-
- it('falls back for invalid timestamp values', () => {
- expect(coerceTimestampMillis('not-a-date', 9999)).toBe(9999);
- expect(coerceTimestampMillis(null, 9999)).toBe(9999);
- });
-});
-
-describe('stringArray', () => {
- it('returns only string entries from array values', () => {
- expect(stringArray(['a', 1, 'b', null])).toEqual(['a', 'b']);
- });
-
- it('returns empty array for non-array values', () => {
- expect(stringArray('a')).toEqual([]);
- });
-});
-
-describe('stringRecordOrUndefined', () => {
- it('returns only string fields from object values', () => {
- expect(stringRecordOrUndefined({ a: 'one', b: 2, c: 'three' })).toEqual({
- a: 'one',
- c: 'three',
- });
- });
-
- it('returns undefined for invalid or empty records', () => {
- expect(stringRecordOrUndefined(null)).toBeUndefined();
- expect(stringRecordOrUndefined({ a: 1, b: false })).toBeUndefined();
- });
-});
-
-describe('normalizeStructuredAPIError', () => {
- it('normalizes code, message, and string details through shared helpers', () => {
- expect(
- normalizeStructuredAPIError(
- {
- code: ' invalid_email ',
- message: ' Invalid email format ',
- details: {
- field: 'email',
- ignored: 42,
- },
- },
- 400,
- ),
- ).toEqual({
- code: 'invalid_email',
- message: 'Invalid email format',
+describe('responseUtils structured API errors', () => {
+ it('reads canonical code and detail fields from shared API errors', () => {
+ const error = {
+ code: 'vmware_connection_failed',
details: {
- field: 'email',
+ category: 'unsupported_version',
+ error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
- });
- });
+ };
- it('falls back when payload fields are missing or invalid', () => {
- expect(normalizeStructuredAPIError(null, 502)).toEqual({
- code: 'request_failed',
- message: 'Request failed (502)',
- });
- expect(normalizeStructuredAPIError({ code: ' ', message: '' }, 503)).toEqual({
- code: 'request_failed',
- message: 'Request failed (503)',
- });
- });
-});
-
-describe('promoteLegacyAlertIdentifier', () => {
- it('promotes legacy alert_identifier into canonical alertIdentifier', () => {
- expect(
- promoteLegacyAlertIdentifier({
- id: 'finding-1',
- alert_identifier: ' legacy-alert ',
- }),
- ).toEqual({
- id: 'finding-1',
- alertIdentifier: 'legacy-alert',
- });
- });
-
- it('prefers canonical alertIdentifier when already present', () => {
- expect(
- promoteLegacyAlertIdentifier({
- id: 'finding-2',
- alertIdentifier: 'canonical-alert',
- alert_identifier: ' legacy-alert ',
- }),
- ).toEqual({
- id: 'finding-2',
- alertIdentifier: 'canonical-alert',
- });
- });
-});
-
-describe('apiErrorStatus', () => {
- it('returns null for non-errors and missing statuses', () => {
- expect(apiErrorStatus(null)).toBeNull();
- expect(apiErrorStatus(new Error('boom'))).toBeNull();
- });
-
- it('returns the canonical numeric status from API errors', () => {
- const error = Object.assign(new Error('Payment Required'), { status: 402 });
- expect(apiErrorStatus(error)).toBe(402);
- });
-
- it('rejects non-http status values', () => {
- expect(apiErrorStatus({ status: '402' })).toBeNull();
- expect(apiErrorStatus({ status: 99 })).toBeNull();
- expect(apiErrorStatus({ status: 600 })).toBeNull();
- });
-});
-
-describe('isAPIErrorStatus', () => {
- it('matches canonical status-bearing API errors', () => {
- const error = Object.assign(new Error('Not Found'), { status: 404 });
- expect(isAPIErrorStatus(error, 404)).toBe(true);
- expect(isAPIErrorStatus(error, 402)).toBe(false);
- });
-});
-
-describe('withAPIErrorStatusFallback', () => {
- it('returns the original result for successful requests', async () => {
- await expect(withAPIErrorStatusFallback(Promise.resolve(['a']), 402, [])).resolves.toEqual([
- 'a',
- ]);
- });
-
- it('returns the fallback for matching API error statuses', async () => {
- await expect(
- withAPIErrorStatusFallback(
- Promise.reject(Object.assign(new Error('Payment Required'), { status: 402 })),
- 402,
- [],
- ),
- ).resolves.toEqual([]);
- });
-
- it('rethrows non-matching errors', async () => {
- await expect(
- withAPIErrorStatusFallback(
- Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })),
- 402,
- [],
- ),
- ).rejects.toThrow('Not Found');
- });
-});
-
-describe('withAPIErrorStatusNull', () => {
- it('returns null for matching API error statuses', async () => {
- await expect(
- withAPIErrorStatusNull(
- Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })),
- 404,
- ),
- ).resolves.toBeNull();
- });
-
- it('returns the successful result for non-error requests', async () => {
- await expect(withAPIErrorStatusNull(Promise.resolve({ id: 'a' }), 404)).resolves.toEqual({
- id: 'a',
- });
- });
-});
-
-describe('apiResponseStatus', () => {
- it('returns null for missing or invalid response statuses', () => {
- expect(apiResponseStatus(null)).toBeNull();
- expect(apiResponseStatus({})).toBeNull();
- expect(apiResponseStatus({ status: '404' })).toBeNull();
- expect(apiResponseStatus({ status: 99 })).toBeNull();
- });
-
- it('returns canonical numeric response status', () => {
- expect(apiResponseStatus(new Response('', { status: 404 }))).toBe(404);
- });
-});
-
-describe('isAPIResponseStatus', () => {
- it('matches canonical status-bearing responses', () => {
- const response = new Response(null, { status: 204 });
- expect(isAPIResponseStatus(response, 204)).toBe(true);
- expect(isAPIResponseStatus(response, 404)).toBe(false);
+ expect(apiErrorCode(error)).toBe('vmware_connection_failed');
+ expect(apiErrorDetailField(error, 'category')).toBe('unsupported_version');
+ expect(apiErrorDetailField(error, 'error')).toBe(
+ 'VMware vCenter 6.7 is below the supported VI JSON release floor',
+ );
});
});
diff --git a/frontend-modern/src/api/responseUtils.ts b/frontend-modern/src/api/responseUtils.ts
index 265446fa1..2e3e7b8d1 100644
--- a/frontend-modern/src/api/responseUtils.ts
+++ b/frontend-modern/src/api/responseUtils.ts
@@ -5,6 +5,9 @@ type APIErrorPayload = {
type APIErrorLike = {
status?: unknown;
+ code?: unknown;
+ detail?: unknown;
+ details?: unknown;
};
type APIResponseLike = {
@@ -13,6 +16,15 @@ type APIResponseLike = {
type APIRecordLike = Record;
+function trimmedOptionalString(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ const normalized = value.trim();
+ return normalized ? normalized : null;
+}
+
function extractErrorMessage(payload: APIErrorPayload): string | null {
if (typeof payload.error === 'string' && payload.error.trim()) {
return payload.error.trim();
@@ -181,6 +193,56 @@ export function apiErrorStatus(error: unknown): number | null {
return status;
}
+export function apiErrorCode(error: unknown): string | null {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+
+ return trimmedOptionalString((error as APIErrorLike).code);
+}
+
+export function apiErrorDetail(error: unknown): string | null {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+
+ return trimmedOptionalString((error as APIErrorLike).detail);
+}
+
+export function apiErrorDetails(error: unknown): Record | null {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+
+ const rawDetails = (error as APIErrorLike).details;
+ if (!rawDetails || typeof rawDetails !== 'object') {
+ return null;
+ }
+
+ const normalizedEntries = Object.entries(rawDetails as APIRecordLike)
+ .map(([key, value]) => {
+ const normalizedKey = trimmedOptionalString(key);
+ const normalizedValue = trimmedOptionalString(value);
+ return normalizedKey && normalizedValue ? [normalizedKey, normalizedValue] : null;
+ })
+ .filter((entry): entry is [string, string] => entry !== null);
+
+ if (normalizedEntries.length === 0) {
+ return null;
+ }
+
+ return Object.fromEntries(normalizedEntries);
+}
+
+export function apiErrorDetailField(error: unknown, field: string): string | null {
+ const details = apiErrorDetails(error);
+ if (!details) {
+ return null;
+ }
+
+ return details[field] ?? null;
+}
+
export function isAPIErrorStatus(error: unknown, expectedStatus: number): boolean {
return apiErrorStatus(error) === expectedStatus;
}
diff --git a/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx b/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx
index eda034521..f01bf01d5 100644
--- a/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx
+++ b/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx
@@ -373,6 +373,25 @@ export const VMwareSettingsPanel: Component = (props)
+
+ {(failure) => (
+
+ {failure().message}
+
+ {failure().guidance}
+
+ >
+ }
+ icon={}
+ />
+ )}
+
+